Running on a server
Using a session from a backend job, with no browser and no session tokens.
Plenty of agent work has no page and no person watching. A nightly job that reconciles invoices, a queue worker that triages an inbox, a build step that asks an agent to summarize a diff — all of these run one session inside one process and then exit.
None of that needs a session token, a browser, or CORS. SubakoClient reaches every session route, so one client does the whole job.
The shape of it
import { SubakoClient } from "@subako-ai/sdk";
const subako = new SubakoClient({
apiKey: process.env["SUBAKO_API_KEY"]!,
});
const created = await subako.sessions.create({ agent_id: agentId, display_name: "nightly" });
const session = subako.sessions.connect(created.id);
await session.ready;
await session.send("Reconcile yesterday's invoices and report anything that does not balance.");
for await (const event of session) {
if (event.type === "run_completed") break;
if (event.type === "run_failed") throw new Error("the run failed");
}
const [answer] = session.transcript.slice(-1);
session.close();
create gives you a session token you never use — nothing here needs one. Ignore it.
Tools, and why never
If the job serves tools, the lifetime is the setting to change:
const client = session.createToolClient("worker", {
lifetime: { type: "never" },
});
client.addTool("lookup_invoice", {
description: "Read one invoice by number.",
schema: z.object({ number: z.string() }),
execute: async ({ number }) => JSON.stringify(await invoices.find(number)),
});
await client.ready;
The default is a 60-second ttl with a ping at half of it, and that default exists for browsers. A tab can be closed, put to sleep, or lose its network without ever telling the server, so the ping is the only way the server learns the tools are gone.
A job is not like that. It runs under a supervisor, it knows when it is finished, and it can send the DELETE itself. { type: "never" } says so: no ping goes out, and the client lives until something deletes it.
The trade is real, though. A never client that is never deleted stays registered, and the agent keeps being offered tools that nothing will answer. A crashed process leaves one behind. So use never where cleanup is guaranteed, and keep the ttl where it is not — a job running somewhere that can be killed without warning is closer to a browser tab than it looks.
Shutting down
Delete the client and close the connection before the process ends.
try {
// ... the work
} finally {
await session.deleteToolClient(client);
session.close();
}
deleteToolClient sends the DELETE, aborts the calls still in flight, and rejects if the server refused — which is worth retrying, because a never client that outlives its process is exactly the case above.
Do not rely on the process staying alive to keep the client serving. The ping interval and the retry timer are both unref’d, so Node exits as soon as nothing else is holding it open. The thing that keeps a job running is your own await, not the SDK’s timers.
Long runs and timeouts
One attempt may take timeout milliseconds, 60 seconds by default. That bounds a single HTTP request, not the session and not a run — a run that thinks for ten minutes is fine, because the events arrive over the stream rather than as one long response.
What a long job does want is more patience on the individual calls, and more retries:
const subako = new SubakoClient({
baseUrl,
apiKey,
timeout: 30_000,
maxRetries: 5,
});
A tool that takes a while is bounded separately: call.signal aborts shortly before the server’s five-minute result deadline, so a long-running handler can stop cleanly rather than have its answer arrive too late to count.
execute: async ({ number }, call) => {
const invoice = await invoices.find(number, { signal: call.signal });
return JSON.stringify(invoice);
},
Reconnects in a job
The connection reconnects on its own while it can, and gives up after maxReconnects consecutive failures — five by default. In a browser that is where you render a “reconnect” button. In a job there is nobody to press it, so either raise the budget or watch the status and call reconnect() yourself:
const session = subako.sessions.connect(created.id, { maxReconnects: 20 });
session.subscribe((state) => {
if (state.status === "failed") {
log.error({ err: state.error }, "session stream failed");
}
});
Not waiting at all
A job does not have to hold a connection open. If the work is “start something and let it run”, post the input and exit:
await subako.sessions.postEvent(created.id, { type: "input", text: prompt });
The run continues on the server. Read the log later, whenever the job next wakes:
const page = await subako.sessions.listEvents(sessionId, { order: "asc", after_seq: lastSeen });
for await (const event of page) {
handle(event);
}
Keeping the last seq you processed is enough to resume. See Reading the log.
Idempotency for a job that gets retried
A queue that redelivers, or a cron that fires twice, should not create two sessions. Pass your own key:
await subako.sessions.create({ agent_id: agentId }, { idempotencyKey: `nightly-${date}` });
The second attempt answers with a receipt describing the first, instead of acting again. Timeouts, retries and idempotency covers what comes back.
What you do not need
- Session tokens. They exist so a browser can hold something narrower than an API key. A backend already holds the key.
allowed_origins. That is CORS, and it only applies to requests a browser makes. A server-to-server call never has anOrigin.@subako-ai/reactand the page tools. They drive a DOM. A job has none.