Start with the status, because it changes the advice. In August 2025 OpenAI announced that the Assistants API was deprecated and would be removed from the API on August 26, 2026, and its deprecations page names the Responses API and the Conversations API as the replacement. That date has passed. A new build should not start on Assistants, and an existing assistant with email tools needs to move.
The good news for email tools is that almost nothing about them changes. The function definitions keep their names and JSON schemas, and the code that calls Lumbox stays the same. What changes is the loop around them: instead of polling a run until it reports requires_action and then submitting tool outputs to the run, your code reads function calls out of a response and sends the results back as input items.
What maps to what
| Assistants API | Responses API |
|---|---|
| Assistant (model, instructions, tools) | A Prompt, or instructions and tools passed per request |
| Thread | Conversation |
| Run | Response |
| Run steps | Items (messages, tool calls, tool outputs) |
required_action.submit_tool_outputs | function_call items in response.output, answered with function_call_output items |
OpenAI's migration guide covers the conversation side, including copying old thread messages into a new Conversation. For the email tools, the work is the tool format and the loop.
The tool definitions
Assistants nested each function under a function key. Responses tools are flat. With strict: true, every property must be listed in required, additionalProperties must be false, and an optional argument is expressed by allowing null:
const tools = [
{
type: "function",
name: "create_inbox",
description: "Create a new email inbox for one signup. Returns its id and address.",
parameters: { type: "object", properties: {}, required: [], additionalProperties: false },
strict: true,
},
{
type: "function",
name: "wait_for_otp",
description: "Wait up to 60 seconds for a verification code sent to an inbox.",
parameters: {
type: "object",
properties: {
inbox_id: { type: "string", description: "The id returned by create_inbox" },
sender: { type: ["string", "null"], description: "Sender domain to match, e.g. github.com" },
},
required: ["inbox_id", "sender"],
additionalProperties: false,
},
strict: true,
},
{
type: "function",
name: "send_email",
description: "Send a plain-text email from an inbox.",
parameters: {
type: "object",
properties: {
inbox_id: { type: "string" },
to: { type: "string" },
subject: { type: "string" },
text: { type: "string" },
},
required: ["inbox_id", "to", "subject", "text"],
additionalProperties: false,
},
strict: true,
},
];
The Lumbox side
This part carries over from an Assistants integration unchanged. Each tool is one call to https://api.lumbox.co/v1 with an X-API-Key header:
const LUMBOX = "https://api.lumbox.co/v1";
const lumboxHeaders = { "X-API-Key": process.env.LUMBOX_API_KEY, "Content-Type": "application/json" };
async function lumbox(method, path, body) {
const res = await fetch(`${LUMBOX}${path}`, {
method,
headers: lumboxHeaders,
body: body ? JSON.stringify(body) : undefined,
});
return { http_status: res.status, ...(await res.json()) };
}
async function runTool(name, args) {
switch (name) {
case "create_inbox":
return lumbox("POST", "/inboxes", {});
case "wait_for_otp": {
const q = new URLSearchParams({ timeout: "60" });
if (args.sender) q.set("from", args.sender);
return lumbox("GET", `/inboxes/${args.inbox_id}/otp?${q}`);
}
case "send_email":
return lumbox("POST", `/inboxes/${args.inbox_id}/send`,
{ to: args.to, subject: args.subject, text: args.text });
default:
return { error: `unknown tool ${name}` };
}
}
The HTTP status goes into http_status, not status. The inbox response already has its own status: "ACTIVE" field, and spreading the body after a status key would silently replace the 201 with it.
The helper returns errors instead of throwing them. When no code arrives within the timeout, /otp answers HTTP 408 with {"error": "No OTP found", "timeout": 60}, and the model receives exactly that. It can decide to click "resend" and call the tool again, which is a better outcome than an exception tearing down your loop.
The loop
import OpenAI from "openai";
const openai = new OpenAI();
const input = [{ role: "user", content: "Sign up at https://example.com/signup and verify the email." }];
for (let turn = 0; turn < 8; turn++) {
const response = await openai.responses.create({ model: "gpt-5.6", tools, input });
input.push(...response.output);
const calls = response.output.filter((item) => item.type === "function_call");
if (calls.length === 0) {
console.log(response.output_text);
break;
}
for (const call of calls) {
const result = await runTool(call.name, JSON.parse(call.arguments));
input.push({ type: "function_call_output", call_id: call.call_id, output: JSON.stringify(result) });
}
}
Every model output item goes back into input, followed by one function_call_output per call with the matching call_id. The turn cap stops a model that keeps calling tools. The browser tool that actually fills the form is left out here; it would be one more entry in tools and one more case in runTool.
Keeping state between turns
The loop above resends the whole input list each turn, which is the simplest thing that works. The alternative is to pass previous_response_id: response.id and send only the new function_call_output items; OpenAI's function calling guide shows both styles. If you backfilled old threads into Conversations, you pass the conversation instead.
The inbox id needs a home outside the model too. An Assistants integration could keep it in thread metadata. Now the model sees it in the create_inbox output and repeats it in later calls, which works within one run. Anything that outlives the run, such as the cleanup job or a later password reset, should read the id from your own database, stored against the account it belongs to.
Long polls fit this loop better
GET /v1/inboxes/:id/otp holds the request open and checks the inbox once a second until a matching code arrives or the timeout (up to 120 seconds) runs out. It looks back five minutes by default, so an email that landed while the model was still producing its tool call is found immediately. With Assistants, that wait happened while a run sat in requires_action. With Responses, the model request has already finished when your code starts waiting, so the minute spent waiting for an email is a minute of your process, not an open run on OpenAI's side, and the model only sees the finished result.
Sending stays under your control
Because your code executes send_email, you decide whether it runs. Two cheap guards: ask a person to confirm before running a send, or pass "dry_run": true to /send, which validates recipients and returns a preview (from, to, subject, first 500 characters of the body) without sending. The free plan allows 3 sends a minute and 100 a month, and a new free account without a verified domain can send 5 in its first hour. Beyond that the API returns 429, which the model sees like any other result.
What does not change
Lumbox inboxes and their ids are independent of OpenAI's objects. An inbox id you stored against an old Assistants thread still works after the migration, and inboxes never expire on their own, so delete the ones you no longer need with DELETE /v1/inboxes/:id. If you would rather not write the loop at all, the OpenAI Agents SDK version of this setup lets the SDK run it. Request and response fields for every endpoint above are in the API reference.