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, and workflows/*.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:

workflows/refund.workflow.ts
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 }.

workflows/qualify-lead.workflow.ts
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-facing stackbone.* 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.

The platform for agent developers.Build, ship and observe agentsthat survive prod.

© 2026 · STACKBONEBUILT WITH ❤️ FROM CANADA AND SPAIN