Operate / OpenAI-compatible API

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

EndpointBehaviour
GET /v1/modelsLists every agent instance as a model.
POST /v1/chat/completionsChat Completions, blocking or SSE streaming.
POST /v1/responsesResponses 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:

terminalbash
# 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"}]}
chat.tstypescript
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:

  1. the instance whose id equals model, otherwise
  2. 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.

foundry.application.tstypescript
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

FieldHandling
modelRequired. Instance id or single-instance route.
messagesRequired, 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.
streamtrue returns SSE chunks.
conversation_idNon-standard body field: conversation key.
userConversation key when neither the header nor conversation_id is set.
header x-foundry-conversation-idConversation 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 user message is the turn. Its string content, or its text parts plus image_url parts (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.
  • system messages never replace the agent's systemPrompt; they arrive as folded text like any other prior message.
what the agent receives for a new conversationtext
<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:

200 OKjson
{
  "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

stream.tstypescript
// 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 ?? "");
}
text/event-streamtext
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 model text_delta event 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 run handler produced the output), the whole answer arrives as one chunk just before finish_reason: "stop".
  • The HTTP status is already 200 when a run fails mid-stream, so the failure arrives as a content chunk Foundry error: <message> followed by finish_reason: "stop" and [DONE].
  • The x-foundry-conversation-id response header is sent with the stream headers.

Responses API

responses.tstypescript
// 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

FieldHandling
modelRequired. Same resolution as above.
inputRequired. 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 partsinput_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.
instructionsPrepended to the turn as <instructions>…</instructions> text (not the system prompt) and echoed back.
streamtrue returns typed SSE events.
conversation_id, user, headerConversation key, same precedence as Chat Completions.
contextNon-standard object merged into the run's request.context.
storeEchoed 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

  • id is resp_<runId>; status is completed, failed or cancelled.
  • output holds one assistant message with one output_text part when completed, otherwise [], with error: { message, type: "foundry_run_error" } when the run recorded an error.
  • metadata contains glove.foundry.run_id and glove.foundry.conversation_id.
  • usage is all zeros; previous_response_id is always null; temperature, top_p, tool_choice and tools are fixed placeholder values.
  • HTTP 200 when 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:

  1. the x-foundry-conversation-id request header,
  2. the conversation_id body field,
  3. the user body field,
  4. 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.

chat.pypython
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="")
terminalbash
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

  • model is an agent, not an LLM. The instance's definition chooses the provider, prompt, tools and memory.
  • Tools run inside Foundry. Client-supplied tools/functions are ignored and no tool_calls are 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_id is 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), with 400 for invalid bodies, 401 unauthorized, 404 for an unknown model or path, 413 for an oversized body and 502 for 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.messageBodyBytes is raised in foundry.config.ts.
  • Only these three endpoints exist. /v1/embeddings, /v1/completions, files and audio return 404.