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
HttpAgentclient, or a plaincurl. Pick the agent with themodelfield, or with its name in the URL for AG-UI. There is no proprietary session protocol to learn.
The key in every example below is a workspace API key, minted in Studio
under Settings → API keys. It is shown once, it looks like
sbk_live_<selector>_<secret>, and it names the agents it may call. Send the
whole string, not the masked preview the key list shows. See
API keys for the format, the permissions and how
to revoke one.
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, the same as calling 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). 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 sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
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], the same as the OpenAI API. The response
lists every tool call the turn made under choices[0].message.tool_calls. A
turn that ran its tools and answered ends finish_reason: "stop"; a turn that
stopped on a call somebody else has to resolve ends
finish_reason: "tool_calls". See
Tools and shared state.
Anthropic Messages
POST /anthropic/v1/messages
x-api-key: sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
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",
"stop_sequence": null,
"usage": {
"input_tokens": 42,
"output_tokens": 9,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
},
}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. Its usage reports cache reads and writes
alongside input/output tokens, on the response above and on the streaming
message_delta. input_tokens excludes the cached counts, so the three
numbers add up instead of double-counting. 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 sbk_live_4f3c2b1a9e8d7c6b_zK7Wq2xR-4nT8vB1yE0sM6dH3jL5pC9fG2aU4iO7rQ8
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. You choose the agent with 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.
state seeds the agent's shared state before the run starts. See
Shared state and generative UI.
Anything the protocol has no event for rides a CUSTOM frame, named by its
name field. Besides usage above, a turn emits browser_live_view (the
live-view URL of a browsing session, as soon as one opens) and guardrail
(the decision a guardrail took on this turn).
Listing agents
GET /openai/v1/models{
"object": "list",
"data": [{ "id": "support", "object": "model", "created": 1735689600, "owned_by": "stackbone" }],
}GET /anthropic/v1/models returns the same names in Anthropic's model-list
shape: { data: [{ type, id, display_name, created_at }], first_id, last_id, has_more }. Both are catalogs only: a client uses them to populate a model
picker, never to route a chat call.
Both catalogs answer through the presented key: they list only the agents that key may chat with, so a client's model picker shows exactly what it can call.
When a call is refused
A refusal arrives before any token streams. The statuses below hold on all three wires, with one exception noted in the table; only the body shape differs.
| Status | When | code |
|---|---|---|
400 |
the body is not a valid request for this wire (no model, empty messages) |
none |
401 |
the credential is missing, empty, unknown, revoked or expired | none |
403 |
the key is valid but was not granted the agent you asked for | permission_denied, or permission_error on Anthropic |
404 |
no agent answers to that name | model_not_found, or agent_not_found on AG-UI |
409 |
the session is waiting on a human decision (AG-UI streams this instead) | approval_pending |
503 |
the agent is registered, but its graph could not be built | model_provider_missing or graph_build_failed |
The OpenAI and AG-UI bodies carry that code. The Anthropic body carries a
type and a message only, so branch on the status there. The 401 is
covered in Authentication, the 403
in API keys, and the 409
in Tool approvals.
Read 503 as "the name is right, the agent cannot answer yet".
model_provider_missing means no model credential reached the workspace.
Once a stream is open the status is already 200, so a later failure rides the
stream: an error frame then data: [DONE] on the OpenAI wire, an error event
on the Anthropic wire, and a RUN_ERROR event on AG-UI.
Point a third-party client at your box
Every client below needs the same two values: the base URL
https://your-box.example.com/openai/v1 and a workspace API key. Mint the key
in Studio under Settings → API keys, grant it agent:chat on the agents the
client should reach, and paste the whole sbk_live_… string into the client's
API-key field. The masked preview in the key list is not a credential and will
be refused.
LibreChat
Add the box to the endpoints.custom list in your librechat.yaml, and put the
key in STACKBONE_API_KEY in LibreChat's environment:
endpoints:
custom:
- name: 'Stackbone'
apiKey: '${STACKBONE_API_KEY}'
baseURL: 'https://your-box.example.com/openai/v1'
models:
default: ['support']
fetch: true
titleConvo: true
titleModel: 'support'
modelDisplayLabel: 'Stackbone'fetch: true populates the model menu from GET /openai/v1/models, which
already answers through the key, so the menu lists the agents that key may call
and nothing else. Keep one name under default as the fallback for a box that
is still starting.
Open WebUI
Open Settings → Connections, add an OpenAI API connection, and give it:
| Field | Value |
|---|---|
| URL | https://your-box.example.com/openai/v1 |
| API key | your sbk_live_… key, in full |
Save, then pick the agent by name in the model selector.
Vercel AI SDK
Use the OpenAI-compatible provider, which posts to /chat/completions under the
base URL you give it:
pnpm add ai @ai-sdk/openai-compatibleimport { createOpenAICompatible } from '@ai-sdk/openai-compatible';
import { generateText } from 'ai';
const stackbone = createOpenAICompatible({
name: 'stackbone',
baseURL: 'https://your-box.example.com/openai/v1',
apiKey: process.env.STACKBONE_API_KEY,
});
const { text } = await generateText({
model: stackbone('support'),
prompt: 'What plans do you offer?',
});curl
curl https://your-box.example.com/openai/v1/chat/completions \
-H "Authorization: Bearer $STACKBONE_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"model": "support",
"messages": [{ "role": "user", "content": "What plans do you offer?" }]
}'A 401 here means the key is unknown, revoked or expired, or that only the
preview was pasted. A 403 means the key is good but was not granted the agent
you asked for. See API keys
for the full refusal table.