OpenAI-compatible API
Any OpenAI SDK, chat UI, LangChain-style client or eval harness can talk to a Foundry agent by changing its base URL. Behind the familiar request, each call is a durable run on a persisted agent instance: its own model, prompt, tools, memory, approvals and trace, in a conversation that survives the request.
What you get
| Endpoint | Behaviour |
|---|---|
GET /v1/models | Lists every agent instance as a model. |
POST /v1/chat/completions | Chat Completions, blocking or SSE streaming. |
POST /v1/responses | Responses API, blocking or SSE streaming, including image and file inputs. |
All three live on the normal Foundry server (default http://127.0.0.1:4141), so the SDK base URL is http://127.0.0.1:4141/v1. Runs they create appear in the inspector and in WebChat like any other conversation. For fire-and-poll automation use /v1/runs instead.
Quick start
The model you send is an agent instance, so create one first (once). With the dev server running:
# Persist an instance of agents/support/agent.ts with a readable id.
curl -s http://127.0.0.1:4141/api/agent-instances \
-H 'content-type: application/json' \
-d '{"definitionId":"support","id":"support-acme","workspaceId":"acme"}'
# It is now a "model".
curl -s http://127.0.0.1:4141/v1/models
# {"object":"list","data":[{"id":"support-acme","object":"model","created":1760518800,"owned_by":"glove-foundry:support"}]}import OpenAI from "openai";
const openai = new OpenAI({
baseURL: "http://127.0.0.1:4141/v1",
apiKey: process.env.FOUNDRY_API_TOKEN ?? "foundry-local", // see Authentication
});
// "model" is a Foundry agent instance id.
const { data: completion, response } = await openai.chat.completions
.create(
{
model: "support-acme",
messages: [{ role: "user", content: "Where is order 1042?" }],
},
{ headers: { "x-foundry-conversation-id": "customer-881" } },
)
.withResponse();
console.log(completion.choices[0]?.message.content);
// The durable conversation id; send it back (or the same key) to continue.
console.log(response.headers.get("x-foundry-conversation-id")); // "openai-…"Models are agent instances
GET /v1/models returns one entry per persisted instance: id is the instance id, created its creation time in Unix seconds and owned_by is glove-foundry:<definition route>. Definitions without instances are not listed. A request's model resolves to:
- the instance whose id equals
model, otherwise - the only instance of the definition route named
model(e.g."support"when exactly one support instance exists).
Anything else is 404 Foundry model "…" does not resolve to one agent instance. These endpoints never create instances; create them with the client or HTTP API, a playbook subscription, or the inspector. The model provider, system prompt and tools are whatever that instance's definition assembles — see Definitions & instances.
Authentication
OpenAI SDKs send Authorization: Bearer <apiKey> and refuse to start without a key. On loopback with no requestAuthorization, Foundry ignores the header, so any non-empty placeholder works. When foundry.application.ts configures requestAuthorization, the adapter receives that header on every request and the SDK's apiKey becomes your Foundry credential; a refusal is 401 with a WWW-Authenticate challenge.
import { timingSafeEqual } from "node:crypto";
import { Effect } from "effect";
import { defineApplication } from "glove-foundry";
const token = process.env.FOUNDRY_API_TOKEN ?? "";
function matches(header: string | undefined): boolean {
const expected = Buffer.from(`Bearer ${token}`);
const received = Buffer.from(header ?? "");
return token.length > 0 && received.length === expected.length && timingSafeEqual(received, expected);
}
export default defineApplication({
name: "Acme support",
requestAuthorization: {
identifier: "acme-bearer-token",
challenge: 'Bearer realm="Acme support"',
authorize: ({ authorization }) => Effect.sync(() => matches(authorization)),
},
});The adapter gates the whole server, inspector included. Production setups (TLS, cookies for the inspector, proxies) are on Deploy & security.
Chat Completions
Request fields Foundry reads
| Field | Handling |
|---|---|
model | Required. Instance id or single-instance route. |
messages | Required, non-empty, must contain a user message. Roles: system, user, assistant, tool; any other role (including developer) is a 400. Content is a string or an array of parts. |
stream | true returns SSE chunks. |
conversation_id | Non-standard body field: conversation key. |
user | Conversation key when neither the header nor conversation_id is set. |
header x-foundry-conversation-id | Conversation key; wins over both body fields. |
Every other field — tools, tool_choice, temperature, max_tokens, response_format, n, stop, metadata, stream_options… — is ignored.
How messages become one Foundry turn
Foundry keeps the history itself, so it sends the agent one new user turn per request:
- The last
usermessage is the turn. Its string content, or itstextparts plusimage_urlparts (converted to Glove image parts with a URL source), are used; other part types are dropped. Messages after it are ignored. - If the conversation is new — first request for this key, or no key at all — every message before that user message is folded into the turn as text. If the conversation already exists, earlier messages are ignored because Foundry already holds them.
systemmessages never replace the agent'ssystemPrompt; they arrive as folded text like any other prior message.
<message role="system">
Reply in Spanish.
</message>
<message role="user">
Hi
</message>
<message role="assistant">
¡Hola! ¿En qué puedo ayudarte?
</message>
<current-user-message>
Where is order 1042?
</current-user-message>A chat UI that resends the full history on every request works either way: pin a conversation key and Foundry uses only the newest user message after the first call; omit the key and every request starts a fresh conversation seeded with the folded history.
Response
Non-streaming requests block until the run finishes (up to the run's timeoutMs, 300 000 ms by default) and return:
{
"id": "chatcmpl-<runId>",
"object": "chat.completion",
"created": 1760518860,
"model": "support-acme",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "Order 1042 shipped on Monday and arrives Thursday." },
"finish_reason": "stop"
}
],
"system_fingerprint": "glove-foundry"
}content is the run's output.value: the final assistant text, or the JSON string of a non-string value returned by a custom run handler. The run id is embedded in id after chatcmpl-; open /runs/<runId> in the inspector to see every tool call. There is no usage field. A run that fails, is cancelled or does not finish in time returns 502 { "error": "…" }.
Streaming
// openai: the client constructed in chat.ts above
const stream = await openai.chat.completions.create(
{
model: "support-acme",
stream: true,
messages: [{ role: "user", content: "Draft a reply to the customer about the delay." }],
},
{ headers: { "x-foundry-conversation-id": "customer-881" } },
);
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta.content ?? "");
}data: {"id":"chatcmpl-<runId>","object":"chat.completion.chunk","created":1760518860,"model":"support-acme","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}
data: {"id":"chatcmpl-<runId>","object":"chat.completion.chunk","created":1760518860,"model":"support-acme","choices":[{"index":0,"delta":{"content":"Order 1042 "},"finish_reason":null}]}
data: {"id":"chatcmpl-<runId>","object":"chat.completion.chunk","created":1760518860,"model":"support-acme","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: [DONE]- The first chunk carries only
role. Each later content chunk is one modeltext_deltaevent from that run, so text the model writes before a tool call is streamed too; the non-streaming body contains only the final message. - If the model adapter does not stream (or a
runhandler produced the output), the whole answer arrives as one chunk just beforefinish_reason: "stop". - The HTTP status is already
200when a run fails mid-stream, so the failure arrives as a content chunkFoundry error: <message>followed byfinish_reason: "stop"and[DONE]. - The
x-foundry-conversation-idresponse header is sent with the stream headers.
Responses API
// openai: the client constructed in chat.ts above
const response = await openai.responses.create(
{
model: "support-acme",
instructions: "Answer in two sentences.",
input: [
{
role: "user",
content: [
{ type: "input_text", text: "Is this box damaged enough for a refund?" },
{ type: "input_image", image_url: "https://example.com/damaged-box.jpg", detail: "auto" },
],
},
],
},
{ headers: { "x-foundry-conversation-id": "customer-881" } },
);
console.log(response.output_text);
console.log(response.metadata?.["glove.foundry.run_id"]);
// Streaming: typed events, ending in response.completed or response.failed.
const events = await openai.responses.create({
model: "support-acme",
input: "Summarise the conversation so far.",
stream: true,
});
for await (const event of events) {
if (event.type === "response.output_text.delta") process.stdout.write(event.delta);
}Request fields Foundry reads
| Field | Handling |
|---|---|
model | Required. Same resolution as above. |
input | Required. A string, or an array of items. From an array, the last item with role: "user" (else the last item) is the turn; every item before that user item is folded in as <message role=…> text — on every request, whether or not the conversation already exists. |
| content parts | input_text/text → text; input_image/image_url with a URL → image; input_file with file_url or file_data (data URL or raw base64) → document. Others are dropped. |
instructions | Prepended to the turn as <instructions>…</instructions> text (not the system prompt) and echoed back. |
stream | true returns typed SSE events. |
conversation_id, user, header | Conversation key, same precedence as Chat Completions. |
context | Non-standard object merged into the run's request.context. |
store | Echoed only; Foundry always persists the run. |
previous_response_id, tools, temperature, max_output_tokens, reasoning and text.format are ignored; continue a conversation with a key instead.
Response
idisresp_<runId>;statusiscompleted,failedorcancelled.outputholds one assistantmessagewith oneoutput_textpart when completed, otherwise[], witherror: { message, type: "foundry_run_error" }when the run recorded an error.metadatacontainsglove.foundry.run_idandglove.foundry.conversation_id.usageis all zeros;previous_response_idis alwaysnull;temperature,top_p,tool_choiceandtoolsare fixed placeholder values.- HTTP
200when completed,502(with the same response object) otherwise.
Streaming emits, in order: response.created, response.in_progress, response.output_item.added, response.content_part.added, any number of response.output_text.delta, response.output_text.done, response.content_part.done, response.output_item.done, then response.completed or response.failed. Each frame is event: <type> plus data with type and an increasing sequence_number.
Continuing a conversation
Every request resolves a conversation key, in this order:
- the
x-foundry-conversation-idrequest header, - the
conversation_idbody field, - the
userbody field, - none: a brand-new conversation for this request.
A key that already starts with the endpoint's prefix — openai- for Chat Completions, responses- for the Responses API — and is shorter than 128 characters is used as the conversation id verbatim, so you can echo back the returned header. Any other key becomes a stable id derived from the instance id and the key (openai-<32 hex>): the same key on the same instance always reaches the same conversation, and the same key on another instance reaches a different one. Because the prefixes differ, the two endpoints never share a conversation. New conversations are titled “OpenAI-compatible session” and can be opened in WebChat or read with conversationTranscript.
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:4141/v1", api_key="foundry-local")
raw = client.chat.completions.with_raw_response.create(
model="support-acme",
messages=[{"role": "user", "content": "Where is order 1042?"}],
extra_headers={"x-foundry-conversation-id": "customer-881"},
)
completion = raw.parse()
print(completion.choices[0].message.content)
print(raw.headers.get("x-foundry-conversation-id"))
# Same key in the body field: same durable conversation.
stream = client.chat.completions.create(
model="support-acme",
messages=[{"role": "user", "content": "And when will it arrive?"}],
stream=True,
extra_body={"conversation_id": "customer-881"},
)
for chunk in stream:
print(chunk.choices[0].delta.content or "", end="")curl -s http://127.0.0.1:4141/v1/chat/completions \
-H 'content-type: application/json' \
-H 'authorization: Bearer foundry-local' \
-H 'x-foundry-conversation-id: customer-881' \
-d '{"model":"support-acme","messages":[{"role":"user","content":"Where is order 1042?"}]}'
curl -N http://127.0.0.1:4141/v1/responses \
-H 'content-type: application/json' \
-d '{"model":"support-acme","input":"Any update?","conversation_id":"customer-881","stream":true}'Differences from OpenAI
modelis an agent, not an LLM. The instance's definition chooses the provider, prompt, tools and memory.- Tools run inside Foundry. Client-supplied
tools/functionsare ignored and notool_callsare returned; you receive the final text. The tool calls are in the run trace. - Sampling parameters are ignored (
temperature,top_p,max_tokens,n,stop,seed,response_format,reasoning…). Configure them on the definition's model adapter. - Server-side history. With a conversation key only the newest user message is new input;
previous_response_idis not used. - One choice, always
finish_reason: "stop", even when the run used tools. - No token usage. Chat Completions omits
usage; Responses reports zeros. Token counts are on the conversation transcript (tokenCount,tokenConsumption). - Errors are
{ "error": "message" }(a string), with400for invalid bodies,401unauthorized,404for an unknown model or path,413for an oversized body and502for a failed run. SDKs surface these as their API error type. - Approvals can hold a request open. A permission-gated tool pauses the run for up to 5 minutes waiting for a human; the HTTP request stays open meanwhile and an expired request is denied. See Approvals & steering.
- Long requests. Non-streaming calls wait for the whole run (default run timeout 5 minutes); set your client timeout above that, or stream.
- Body size is capped at 1 MB unless
server.messageBodyBytesis raised infoundry.config.ts. - Only these three endpoints exist.
/v1/embeddings,/v1/completions, files and audio return404.