A Stackbone container runs a workspace: one or more in-process agents (multi-turn chat, selected by name) plus durable workflows. You do not hand-roll an HTTP server. The HTTP surface is owned by the Stackbone workspace runtime; you author files (an agent's
index.ts, its tools, andworkflows/*.ts) with@stackbone/sdk. Chat, the model catalog and the workflow routes are all served for you.This page is the behavioural contract: the HTTP shapes your workspace serves, how they are authenticated, and the env the runtime injects. For the underlying model, see Agents, Workflows, and Stackbone Connect.
What a workspace serves
A workspace exposes three layers of HTTP, all on the same server:
| Layer | Routes | Purpose |
|---|---|---|
| Agent chat | POST /openai/v1/chat/completions, GET /openai/v1/models, POST /anthropic/v1/messages, GET /anthropic/v1/models |
talk to any agent as an OpenAI or Anthropic client |
| Agent chat (AG-UI) | POST /agui/v1/agents/:name |
drive an agent from an AG-UI client |
| Workflow runtime | POST /api/workflows/:name/start, POST /api/workflows/:name/chat, GET /api/workflows, GET /api/discovery, GET /live, GET /health |
start and inspect durable workflow runs |
You consume all three as a stable contract. You never implement any of them: they are produced by the framework from the files you author.
Conversing with an agent
An agent speaks three standard wire formats directly, so any client built for
OpenAI Chat Completions,
Anthropic Messages, or
AG-UI works against it with nothing but a base URL
and a key: LibreChat, Open WebUI, the Vercel AI SDK, LangChain, the AG-UI
HttpAgent client, or a plain curl. There is no proprietary session
protocol to learn.
Pick an agent with the model field, or its URL for AG-UI. A workspace
can hold many agents. For OpenAI and Anthropic, the request's model names
the one that should answer, and GET /models lists the agent names so a
client can populate a dropdown (chat itself never routes through /models,
only through the POST endpoints below). AG-UI carries no model field: you
pick the agent by putting its name in the URL, POST /agui/v1/agents/:name.
The OpenAI and Anthropic wires are stateless by default: you send the
full messages[] array on every call, exactly like talking to OpenAI or
Anthropic directly. There is no session id or continuation token to carry
forward unless you opt in with a header (see Session keys
below). AG-UI works the other way around: its threadId is a durable
session key, always on, so you send only the newest turn once a thread
exists. See AG-UI below.
OpenAI Chat Completions
POST /openai/v1/chat/completions
Authorization: Bearer
Content-Type: application/json
{
"model": "support",
"messages": [{ "role": "user", "content": "What plans do you offer?" }],
"stream": false
} {
"id": "chatcmpl-…",
"object": "chat.completion",
"created": 1735689600,
"model": "support",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "We offer Free, Pro and Team plans." },
"finish_reason": "stop",
},
],
"usage": { "prompt_tokens": 42, "completion_tokens": 9, "total_tokens": 51 },
}Set "stream": true to get chat.completion.chunk server-sent events
instead, ending with data: [DONE], exactly like the OpenAI API. A turn
that calls a tool sets finish_reason: "tool_calls" and lists the calls
under choices[0].message.tool_calls.
Anthropic Messages
POST /anthropic/v1/messages
x-api-key:
Content-Type: application/json
{
"model": "support",
"max_tokens": 1024,
"messages": [{ "role": "user", "content": "What plans do you offer?" }]
} {
"id": "msg_…",
"type": "message",
"role": "assistant",
"model": "support",
"content": [{ "type": "text", "text": "We offer Free, Pro and Team plans." }],
"stop_reason": "end_turn",
"usage": { "input_tokens": 42, "output_tokens": 9 },
}This is the richer of the two wires: it carries first-class thinking
blocks (the agent's reasoning, when the model produces any) and tool_use
blocks with structured input, and its streaming message_delta.usage
reports cache reads/writes alongside input/output tokens. Set
"stream": true for the same message_start / content_block_* /
message_delta / message_stop events Anthropic's own API emits.
AG-UI
POST /agui/v1/agents/support
Authorization: Bearer
Content-Type: application/json
{
"threadId": "thread-1",
"runId": "run-1",
"messages": [{ "id": "msg-1", "role": "user", "content": "What plans do you offer?" }],
"tools": [],
"context": [],
"state": null
} The response is always a Server-Sent-Events stream, even for a one-line reply: AG-UI has no non-streaming mode. Each frame is one AG-UI event, encoded with the protocol's own event encoder:
data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}
data: {"type":"TEXT_MESSAGE_START","messageId":"msg_1","role":"assistant"}
data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"msg_1","delta":"We offer Free, Pro and Team plans."}
data: {"type":"TEXT_MESSAGE_END","messageId":"msg_1"}
data: {"type":"CUSTOM","name":"usage","value":{"inputTokens":42,"outputTokens":9,"cacheReadTokens":0,"cacheWriteTokens":0}}
data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1","outcome":{"type":"success"}}threadId and runId are both required: you mint them, the agent echoes
them back verbatim. There is no model field. The agent is chosen by the
:name in the URL, so GET /models has no AG-UI equivalent either.
Every reasoning block the model produces streams as its own
REASONING_START / REASONING_MESSAGE_* / REASONING_END pair, and every
tool call as TOOL_CALL_START / TOOL_CALL_ARGS / TOOL_CALL_END, plus a
TOOL_CALL_RESULT once a server-run tool returns. AG-UI is the only one of
the three wires with a dedicated event for a tool's result.
tools declares tools your frontend can execute (a modal, a browser
API, anything that only makes sense client-side). See
Frontend-executed tools below. state seeds the
agent's shared state before the run starts. See
Shared state and generative UI below.
Listing agents
GET /openai/v1/models{ "object": "list", "data": [{ "id": "support", "object": "model", "owned_by": "stackbone" }] }GET /anthropic/v1/models returns the same names in Anthropic's model-list
shape. Both are catalogs only: a client uses them to populate a model
picker, never to route a chat call.
Authentication
All three wires expect a bearer credential: Authorization: Bearer <key>
(the convention the OpenAI SDK sends) or x-api-key: <key> (the Anthropic
SDK's header) both work on any of them. A missing or empty credential
returns 401 with the matching wire's error shape ({"error": {...}}
for OpenAI, {"type": "error", "error": {...}} for Anthropic,
{"type": "RUN_ERROR", "message": "..."} for AG-UI).
Session keys
A plain chat call is stateless: you replay the full messages[] array each
turn, exactly like calling OpenAI or Anthropic directly. The server keeps no
transcript and records each turn on its own, so a stateless conversation is
never grouped into a single session.
Two optional headers change that, and they do different things:
| Header | What it does | Where history lives |
|---|---|---|
x-stackbone-session |
Durable server-side session. The agent threads the conversation's history and tool state across calls, so you send only the newest user turn and the server carries the rest. Required for tool approvals (see below). | On the server |
x-stackbone-conversation |
Grouping only. You still replay the full messages[], but the server joins the turns into one session so they show up together instead of as separate one-off runs. No durable state, no tool approvals. |
On the client (you replay it) |
Both take any non-empty string you choose, stable for the life of one conversation. Send at most one. With neither header, each turn is an independent run and nothing is grouped.
AG-UI doesn't use either header: its threadId field on the request body
already carries the same durable, only-send-the-newest-turn semantics as
x-stackbone-session, and it's mandatory rather than opt-in. A non-empty
threadId is always a durable session; leaving it empty runs one stateless
turn with no pause capability.
Tool approvals
An agent can pause a turn on a sensitive tool call until a human decides.
When a session has a decision pending, the next chat call against it
returns 409 before doing any work, so you never stream fresh input into a
paused thread:
{
"error": {
"message": "A tool approval is pending for this session — decide it before sending a new message.",
"type": "invalid_request_error",
"code": "approval_pending",
},
}The pending tool call itself already streamed to you as a normal
tool_calls (OpenAI) or tool_use (Anthropic) block before the turn
stopped, so a caller that already inspects tool calls sees nothing unusual
until it tries to send the next message. The decision is made from
Stackbone Studio, not over this wire.
Tool approvals over AG-UI
AG-UI models the same pause as a first-class interrupt, and lets the
client resolve it directly instead of going through Stackbone Studio. A
paused run closes with a RUN_FINISHED carrying an interrupt outcome
instead of a plain success:
{
"type": "RUN_FINISHED",
"threadId": "thread-1",
"runId": "run-1",
"outcome": {
"type": "interrupt",
"interrupts": [{ "id": "int_abc", "reason": "tool_call", "toolCallId": "call_1" }],
},
}You resolve it by sending the decision back in the next request's
top-level resume array, addressed to the interrupt's id:
{
"threadId": "thread-1",
"runId": "run-2",
"messages": [],
"resume": [{ "interruptId": "int_abc", "status": "resolved", "payload": { "approved": true } }],
}Reject a call with { "approved": false }, or edit its arguments before it
runs with { "editedArgs": { "...": "..." } }. A status: "cancelled"
entry always rejects. Sending a fresh message to a thread with an
unresolved interrupt still fails, but as a protocol event on the stream
(a RUN_ERROR) rather than a bare 409, since AG-UI clients only watch the
event stream, never a response status code.
Frontend-executed tools
RunAgentInput.tools declares tools that only make sense in the browser: an
API only reachable from the client, reading local state, or opening a
modal. When the agent calls one of these, the run finishes normally
(outcome: { "type": "success" }), not as an interrupt: there's nothing to
approve, your frontend just runs the tool.
You send the result back as a trailing role: "tool" message on the same
threadId, and the agent picks up where it left off:
{
"threadId": "thread-1",
"runId": "run-3",
"messages": [{ "role": "tool", "toolCallId": "call_2", "content": "{\"opened\":true}" }],
}This requires a durable thread, exactly like tool approvals: a stateless
run (no threadId) has nothing to resume against.
Shared state and generative UI
Beyond messages, an agent can publish a shared state object for your
UI to render live, for example a task list or a work-in-progress draft.
A run opens with a STATE_SNAPSHOT (the full object) and closes with a
STATE_DELTA when something changed, an RFC 6902
JSON Patch you apply with any off-the-shelf patch library:
{ "type": "STATE_DELTA", "delta": [{ "op": "replace", "path": "/todos/0/done", "value": true }] }You can seed that state from the UI too, by sending state on a request.
The agent's own code decides which keys a client is allowed to write, with
editableState on defineDeepAgent(...):
export default defineDeepAgent({
model: 'openai/gpt-4o-mini',
systemPrompt: '...',
editableState: {
draft: { type: 'string' },
},
});Any key not listed there is dropped from an incoming state payload. Like
frontend tools, seeding requires a durable thread: there's no graph state
to write to on a stateless run.
If you reopen an existing thread, the run also opens with a
MESSAGES_SNAPSHOT, the full prior conversation, so your UI doesn't have
to replay history itself. And whenever the agent hands off to a subagent,
the run brackets that stretch of work with STEP_STARTED /
STEP_FINISHED, so you can show which phase is currently running.
Durable workflows
A workflow is a 'use workflow' function under workflows/. Durability
comes from the upstream
Workflow SDK: each 'use step' runs once,
its result is persisted to an append-only event log, and the run is
deterministically replayed on restart, so a workflow can pause (sleep,
hooks) for hours or days and resume exactly where it left off. See
Workflows for the authoring model.
Start a run
POST /api/workflows/:name/start
Content-Type: application/json
{ "orderId": "ord_42", "amount": 19.99 }The input is validated against the workflow's input schema at the
frontier. A bad input returns 400 with code: "workflow_input_invalid"
and the offending { path, message } issues, and no run is started. A
valid input returns the run handle:
{
"workflowName": "refund",
"runId": "…", // correlate with `stackbone runs get <runId>`
"worldRunId": "…",
"trigger": "manual",
}Stream a run
POST /api/workflows/:name/chat
Content-Type: application/json
{ "…": "…" }A server-sent-event stream: a leading run frame carrying the worldRunId
for correlation, followed by the run's own event frames as its steps
execute (including any agent turn a step delegates to).
Catalog and schema
| Route | Returns |
|---|---|
GET /api/workflows |
the workflow catalog (name, trigger, whether it has a schema) |
GET /api/workflows/:name |
one workflow's detail |
GET /api/workflows/:name/schema |
the workflow's input/output JSON Schema |
GET /api/discovery |
the combined { agents, workflows } view |
From the CLI, the same data is stackbone workflows list and
stackbone workflows schema <name>, and you start a run by name with
stackbone workflows start <name>.
Human-in-the-loop for workflows
A workflow pauses for a human decision by calling requestApproval() from
@stackbone/sdk/workflow (the raw defineHook + sleep are the escape
hatch underneath it). The run parks durably until the decision arrives:
import { z } from '@stackbone/sdk';
import { requestApproval } from '@stackbone/sdk/workflow';
export async function refundWorkflow(input: {
orderId: string;
amount: number;
approvalToken: string;
}) {
'use workflow';
const decision = await requestApproval({
token: input.approvalToken,
topic: 'refund',
payload: { orderId: input.orderId, amount: input.amount },
title: 'Approve refund',
timeout: '24h',
fallback: 'reject',
});
if (decision.status !== 'approved') {
return { orderId: input.orderId, refunded: false };
}
// …perform the refund in a 'use step'…
return { orderId: input.orderId, refunded: true };
}The parked run is resumed when a decision is posted to its hook:
| Route | Purpose |
|---|---|
POST /api/workflows/hooks/:token/resume |
record a decision and resume the run |
GET /api/workflows/runs/:traceId/hooks |
list a run's pending hooks |
The runtime owns this round-trip: you call requestApproval() in the
workflow body, never a webhook receiver. From the CLI it is
stackbone hitl list|get|approve|reject.
Calling an agent from a workflow
A workflow step reaches an agent in-process, not over this HTTP surface:
callDeepAgent(name, input) from @stackbone/sdk/workflow runs one turn of
the named agent in the same process and resolves with { text }.
import { callDeepAgent } from '@stackbone/sdk/workflow';
async function askAgent(question: string) {
'use step';
return callDeepAgent('lead-qualifier', question);
}See Workflow agents for the full pattern.
Health and liveness
The workspace splits liveness from health:
| Route | Behaviour |
|---|---|
GET /live |
Instant 200 { status: "ok" }. No probes, no awaits: the platform liveness signal. |
GET /health |
Runs every subsystem probe in parallel. 200 { status: "ok", checks } or 503 { status: "degraded", checks }. |
The deep /health reports the subsystems your workspace depends on:
{
"status": "ok",
"checks": {
"database": { "ok": true },
"redis": { "ok": true },
},
}A single failing subsystem degrades the whole response to 503. The
platform uses /live for the liveness probe so a slow subsystem never gets
the container recycled by mistake.
Runtime environment
The runtime injects these into the container as a behavioural contract: you read them, you never set them:
| Variable | What it is |
|---|---|
STACKBONE_INSTALLATION_ID |
the install this container serves |
STACKBONE_API_URL |
the control-plane URL the SDK talks to |
DATABASE_URL |
the per-install Postgres connection (the agents' + workflows' data plane) |
WORKFLOW_REDIS_URL |
the per-install Redis backing durable workflow runs |
OPENROUTER_API_KEY / OPENROUTER_BASE_URL |
the model gateway credentials an agent reads |
PORT |
the port the workspace server binds |
In day-to-day code you rarely read these directly: the ambient stackbone
client (stackbone.database, .secrets, .config, …) wires them for you.
The runtime also serves
GET /api/contract, the Stackbone Agent Protocol handshake that advertises the agent-facingstackbone.*capabilities (database, storage, rag, secrets, …). That is the SDK capability handshake; it is distinct from the HTTP endpoints above that you drive (chat, workflows, runs, hooks).
Local development
stackbone dev brings up the same contract on http://127.0.0.1:4242: the
chat endpoints and the workflow start/chat routes, all agents running
in-process behind one server. The local stack is Postgres + Redis + MinIO;
there is nothing to install or sign by hand, and any non-empty bearer key
is accepted locally.
See Local development for the local loop and Getting started to scaffold a workspace.