Client & HTTP API
One Foundry process serves everything over HTTP on 127.0.0.1:4141: a typed control plane for your own TypeScript code, an OpenAI-compatible API for existing SDKs and chat UIs, and an asynchronous run API for automation. All three drive the same persisted instances, conversations and runs, and every request shows up in the inspector.
Three ways to talk to Foundry
| You are building… | Use | What you get |
|---|---|---|
| A TypeScript backend that manages instances, conversations, installations or approvals | Typed client: createFoundryClient from glove-foundry/client (native /api) | The whole control plane, route names checked against your files, run handles with wait/cancel/steer. |
| An app, chat UI or eval harness that already speaks OpenAI | /v1/chat/completions, /v1/responses, /v1/models — see OpenAI-compatible API | Drop-in request/response and SSE streaming; the request blocks until the run ends. |
| A cron job, CI step or service in another language that fires work and checks back later | /v1/runs automation API | 202 Accepted immediately, then status, event stream, stop and steer by run id. |
Host and port come from server.host / server.port in foundry.config.ts (defaults 127.0.0.1 and 4141). Foundry refuses to bind a non-loopback host unless foundry.application.ts sets requestAuthorization; once set, every request — inspector HTML, /api, /v1, /health and event streams — must pass it, or gets 401. See Deploy & security.
The typed client
createFoundryClient<FoundryRoutes>() is a thin, dependency-free fetch wrapper. FoundryRoutes is generated into .foundry/routes.d.ts by glove foundry dev/start, so foundry.agent("support") fails to compile if agents/support/agent.ts does not exist.
import { createFoundryClient } from "glove-foundry/client";
import type { FoundryRoutes } from "../.foundry/routes.js";
const foundry = createFoundryClient<FoundryRoutes>({
baseUrl: process.env.FOUNDRY_URL ?? "http://127.0.0.1:4141",
// Optional: only needed when foundry.application.ts sets requestAuthorization.
authorization: {
identifier: "foundry-api-token",
headers: () => ({ authorization: `Bearer ${process.env.FOUNDRY_API_TOKEN}` }),
},
});
// 1. Persist an instance of agents/support/agent.ts (the route is type-checked).
const support = await foundry.agent("support").create({
id: "support-acme",
workspaceId: "acme",
context: { tenant: "acme", tier: "pro" },
});
// 2. Open a durable conversation owned by that instance.
const conversation = await foundry.createConversation(support.id, {
title: "Order 1042",
});
// 3. Every message becomes a run. send() returns as soon as the run is queued.
const handle = await foundry.send(support.id, conversation.id, "Where is order 1042?");
console.log(`trace: http://127.0.0.1:4141/runs/${handle.id}`);
// 4. Wait for a terminal status: completed, failed or cancelled.
const run = await handle.wait({ timeoutMs: 120_000 });
if (run.status === "completed") {
console.log(run.output?.value); // the final assistant text
} else {
console.error(run.status, run.error);
}| FoundryClientOptions | Default | Meaning |
|---|---|---|
baseUrl | http://127.0.0.1:4141 | Foundry origin; a trailing slash is stripped. |
fetch | globalThis.fetch | Custom fetch (tests, proxies, edge runtimes). |
authorization | none | { identifier, headers({ url, method }) }; headers may be async and runs before every request, so tokens can be refreshed outside Foundry. |
Every method throws an Error whose message is the server's error field when the response is not 2xx. create throws if the instance id already exists — look it up with agentInstances("support") first if your script is re-runnable.
foundry.agent(route)
Returns exactly two functions bound to a definition route:
create(options?)→POST /api/agent-instanceswithdefinitionId: route. Options:id,workspaceId(default"default"),context,installations,playbooks.request(request: FoundryRequest)→POST /api/agents/:route/runs, returning aFoundryRunHandle. You must passagentId,conversationIdandworkspaceId; Foundry rejects the run unless the instance uses this route's definition, the conversation belongs to the instance and all three share the workspace. Over this endpointmessagemust be a non-empty string.
// continues scripts/ask-support.ts above (foundry, support, conversation)
const summary = await foundry.agent("support").request({
agentId: support.id,
conversationId: conversation.id,
workspaceId: support.workspaceId,
message: "Summarise this conversation for the ticket.",
payload: { ticketId: "T-77" }, // reaches the agent as request.payload
});
await summary.wait();For everyday chat, prefer send(agentId, conversationId, message, { payload?, context? }): it looks up the definition and workspace for you and accepts multimodal content parts.
Run handles
send, agent(route).request and handle.steer return a FoundryRunHandle:
| Member | Behaviour |
|---|---|
id | Run id (also the inspector URL /runs/:id). |
initial | The FoundryRun returned when the run was accepted. |
get() | Fresh FoundryRun (GET /api/runs/:id). |
wait({ pollMs?, timeoutMs?, signal? }) | Polls until completed, failed or cancelled and returns the run — it does not throw on failed. Defaults: pollMs 150, timeoutMs 60 000. Throws on timeout or abort; neither cancels the run. |
cancel() | Cooperative cancel; resolves true if the runtime accepted it. |
steer(message) | Interrupt-and-restart in the same conversation. Resolves { fromRunId, interrupted, run } where run is a new handle. See Steering. |
events() | The run's retained FoundryEvents (latest 500). |
A run's own timeout (run.timeoutMs) is 300 000 ms by default, but wait() gives up after 60 s unless you pass timeoutMs. Long tool-using turns need a larger value.
Client method reference
Every public FoundryClient method, grouped by what it manages.
| Method | Returns | HTTP |
|---|---|---|
| Instances | ||
agent(route) | { create, request } | see above |
createAgent(definitionId, options?) | AgentInstance | POST /api/agent-instances |
agentInstances(definitionId?) | AgentInstance[] | GET /api/agent-instances |
configureAgent(agentId, { context?, installations?, playbooks? }) | AgentInstance | PATCH /api/agent-instances/:id |
setAgentPlaybooks(agentId, playbooks) | AgentInstance | PUT /api/agent-instances/:id/playbooks |
| Conversations | ||
createConversation(agentId, { id?, workspaceId?, title?, context? }) | Conversation | POST /api/conversations |
conversations(agentId) | Conversation[] | GET /api/conversations?agent= |
updateConversation(agentId, conversationId, { title?, context? }) | Conversation | PATCH /api/conversations/:id |
conversationTranscript(agentId, conversationId, { offset?, limit? }) | FoundryConversationTranscript | GET /api/conversations/:id/messages |
| Runs | ||
send(agentId, conversationId, message, { payload?, context? }) | FoundryRunHandle | POST /api/conversations/:id/messages |
getRun(runId) | FoundryRun | GET /api/runs/:id |
runs(route?) | FoundryRun[] | GET /api/runs?agent= |
cancelRun(runId) | boolean | POST /api/runs/:id/cancel |
steerRun(runId, message) | FoundrySteerResult (plain run record, not a handle) | POST /api/runs/:id/steer |
getEvents({ runId?, agent?, after?, category? }) | FoundryEvent[] | GET /api/events |
| Approvals | ||
approvals({ runId?, status? }) | FoundryApproval[] | GET /api/approvals |
resolveApproval(approvalId, "approve" | "deny") | FoundryApproval | POST /api/approvals/:id |
| Workspace data | ||
workspaceEntries(workspaceId) / putWorkspaceEntry(workspaceId, key, value) | WorkspaceEntry[] / WorkspaceEntry | GET / PUT /api/workspaces/:id/entries |
sharedInbox(workspaceId) / postSharedInbox(workspaceId, { topic, payload?, agentId?, conversationId? }) | SharedInboxItem[] / SharedInboxItem | GET / POST /api/workspaces/:id/inbox |
updateSharedInbox(workspaceId, itemId, status) | SharedInboxItem | PATCH /api/workspaces/:id/inbox/:itemId |
tasks(workspaceId) / createTask(workspaceId, { title, detail?, agentId?, conversationId? }) | FoundryTask[] / FoundryTask | GET / POST /api/workspaces/:id/tasks |
updateTask(workspaceId, taskId, status) | FoundryTask | PATCH /api/workspaces/:id/tasks/:taskId |
dataEnvironment(workspaceId, { agentId?, conversationId? }?) | EnvironmentValue[] | GET /api/workspaces/:id/environment |
| Transmissions, playbooks & activations | ||
dispatchInbound({ routeId, eventId, threadKey, conversationKey?, conversationScope?, raw }) | FoundryRun[] | POST /api/transmissions/:routeId/fire |
dispatchOutbound({ routeId, agentId, runId, payload }) | adapter output | POST /api/transmissions/:routeId/deliver |
playbookSubscriptions(workspaceId?) | PlaybookSubscription[] | GET /api/playbook-subscriptions |
putPlaybookSubscription(subscription) / deletePlaybookSubscription(id) | PlaybookSubscription / boolean | PUT / DELETE /api/playbook-subscriptions |
activations(workspaceId?) | FoundryActivationRecord[] | GET /api/activations |
| Application connections | ||
applicationConnections() | ApplicationConnectionState[] | GET /api/application-connections |
reconnectApplicationConnection(id) | void | POST /api/application-connections/:id/reconnect |
| Definitions & installations | ||
manifest() | { agents, application, definitions } | GET /api/manifest |
capabilities(definitionId) | FoundryCapabilityManifest | GET /api/capabilities?definition= |
surfaces(definitionId) | FoundryNativeManifest | GET /api/surfaces?definition= |
installations(agentId) | AgentInstallation[] | GET /api/installations?agent= |
install(agentId, { kind, id, accountId?, config? }) / uninstall(agentId, …) | AgentInstance | PUT / DELETE /api/installations |
| Topology & grants | ||
accounts() | AccountSummary[] | GET /api/accounts |
routes() / putRoute(route) / removeRoute(id) | Route[] / Route / void | GET / PUT /api/routes, DELETE /api/routes/:id |
bindings() / putBinding(binding) / removeBinding(id) | AgentBinding[] / AgentBinding / void | GET / PUT /api/bindings, DELETE /api/bindings/:id |
resolveGrant({ runId, agentId, originRouteId? }) | RunGrant | POST /api/grants/resolve |
| Health | ||
health() | FoundryHealth | GET /health |
The client has no wrapper for /v1/*, /health/detailed, GET /api/agents or GET /api/transmissions; call those with fetch.
Native HTTP API
These are the routes the typed client and the inspector use. Request and response bodies are JSON. Errors are always { "error": "message" }: 401 from authorization, 404 for an unknown path or run, 413 for an oversized body, and 400 for everything else — including unknown instance, conversation or definition ids.
| Endpoint | Body / query | Result |
|---|---|---|
| Inspector & health | ||
GET / and any other non-API path | — | Inspector HTML (WebChat lives at /chat/:conversationId?agent=:agentId). |
GET /health | — | FoundryHealth |
GET /health/detailed | — | Health plus connections and every retained run. |
| Definitions | ||
GET /api/manifest | — | { agents, application, definitions: { [route]: { capabilities, surfaces } } } |
GET /api/agents | — | Discovered definitions. |
GET /api/capabilities | ?definition= (required) | The definition's installable catalogue. |
GET /api/surfaces | ?definition= (required) | Layers and subscribers. |
| Instances & installations | ||
GET /api/agent-instances | ?definition= | AgentInstance[] |
POST /api/agent-instances | definitionId, id?, workspaceId?, context?, installations?, playbooks? | 201 AgentInstance |
PATCH /api/agent-instances/:id | context?, installations?, playbooks? — each given field is replaced whole | AgentInstance |
PUT /api/agent-instances/:id/playbooks | playbooks | AgentInstance |
GET /api/installations | ?agent= (required) | AgentInstallation[] |
PUT /api/installations | agentId, kind (tool | application | mcp), id, accountId?, config? | Installs; returns AgentInstance. |
DELETE /api/installations | same body | Uninstalls; returns AgentInstance. |
| Conversations & runs | ||
GET /api/conversations | ?agent= (required) | Conversation[] |
POST /api/conversations | agentId, id?, workspaceId?, title?, context? | 201 Conversation |
PATCH /api/conversations/:id | agentId, title? (1–120 chars), context? | Conversation |
GET /api/conversations/:id/messages | ?agent= (required), offset, limit (1–500, default 100 = latest page) | FoundryConversationTranscript |
POST /api/conversations/:id/messages | agentId, message, payload?, context? | 202 FoundryRun |
POST /api/agents/:route/runs | FoundryRequest (string message) | 202 FoundryRun |
GET /api/runs | ?agent= (definition route) | FoundryRun[] |
GET /api/runs/:id | — | FoundryRun or 404 |
GET /api/runs/:id/events | — | Up to 5 000 events of that run. |
POST /api/runs/:id/cancel | — | { cancelled: boolean } |
POST /api/runs/:id/steer | message (or input / guidance) | 202 { fromRunId, interrupted, run } |
GET /api/events | runId, agent, category, after, limit | JSON array, or SSE with Accept: text/event-stream |
| Approvals | ||
GET /api/approvals | runId (or run_id), status | FoundryApproval[], oldest first |
POST /api/approvals/:id | decision: "approve" | "deny" | FoundryApproval |
| Workspaces | ||
GET / PUT /api/workspaces/:id/entries | PUT: key, value | entries / WorkspaceEntry |
GET / POST /api/workspaces/:id/inbox | POST: topic, payload?, agentId?, conversationId? | items / 201 item (pending) |
PATCH /api/workspaces/:id/inbox/:itemId | status: pending | resolved | dismissed | SharedInboxItem |
GET / POST /api/workspaces/:id/tasks | POST: title, detail?, agentId?, conversationId? | tasks / 201 task (open) |
PATCH /api/workspaces/:id/tasks/:taskId | status: open | in-progress | completed | cancelled | FoundryTask |
GET /api/workspaces/:id/environment | ?agent=, ?conversation= | Non-secret EnvironmentValue[] |
| Automation & integrations | ||
GET /api/activations | ?workspace= | Schedule and sleep records. |
GET / PUT /api/playbook-subscriptions | GET ?workspace=; PUT a PlaybookSubscription | subscriptions / the saved one |
DELETE /api/playbook-subscriptions/:id | — | { removed: boolean } |
GET /api/transmissions | — | Compiled transmission manifest. |
POST /api/transmissions/:routeId/fire | eventId, threadKey, conversationKey?, conversationScope? (route | agent), raw | 202 runs started (an event id is delivered once) |
POST /api/transmissions/:routeId/deliver | agentId, runId, payload | Outbound adapter output. |
GET /api/application-connections | — | Connection states. |
POST /api/application-connections/:id/reconnect | — | { ok: true } |
GET /api/accounts | — | Safe account summaries (no secrets). |
GET / PUT /api/routes, DELETE /api/routes/:id | PUT a Route | routes / route / { removed: true } |
GET / PUT /api/bindings, DELETE /api/bindings/:id | PUT an AgentBinding | bindings / binding / { removed: true } |
POST /api/grants/resolve | runId, agentId, originRouteId? | RunGrant |
To install an application bound to an account over HTTP, add accountId to the PUT /api/installations body. With the client: client.install(agentId, { kind: "application", id, accountId }).
Live event streams
GET /api/events returns the latest 500 matching events as JSON (limit up to 5 000). Send Accept: text/event-stream and the same URL first replays matching retained events, then pushes new ones as they happen:
curl -N -H 'accept: text/event-stream' \
'http://127.0.0.1:4141/api/events?runId=RUN_ID'
: glove-foundry
id: 412
data: {"id":"…","sequence":412,"timestamp":"…","type":"agent.text_delta","category":"model","agent":"support","runId":"RUN_ID","data":{"text":"Order 1042 "}}- Each frame carries
id: <sequence>. To resume after a disconnect, pass?after=<last sequence>; theLast-Event-IDheader is not read. - A
: heartbeatcomment is written every 15 s./api/eventsstreams stay open;/v1/runs/:id/eventsstreams close after the run's terminal event. - Events live in memory (
observability.maxEvents, default 10 000) and are not persisted across restarts. Durable history is the conversation store; see Inspector & WebChat. FoundryEventfields:id,sequence,timestamp,type,category(agent,run,model,tool,extension,application,inbox,memory,mcp,activation,system,log),agent?,runId?,data. Events emitted inside an agent process are prefixedagent..
Health checks
GET /health returns FoundryHealth: { ok, execution, environment, activations, agents, capabilities, surfaces }. ok is true when the execution daemon, its environment and the activation scheduler all answer a ping; the last three fields are counts. GET /health/detailed adds connections and the full retained runs list, so keep it off public load-balancer probes. Both answer 200 when ok is true and 503 with the same body when it is false; client.health() returns the body either way. Both pass through requestAuthorization when it is configured.
/v1/runs automation API
Fire work from anything that can send HTTP, get a run id back immediately, and check on it later. Runs land in a durable conversation, so a nightly job can keep one conversation per job name.
| Endpoint | Result |
|---|---|
POST /v1/runs | 202 FoundryRun + x-foundry-conversation-id header |
GET /v1/runs/:id | FoundryRun or 404 |
GET /v1/runs/:id/events | JSON array, or SSE with Accept: text/event-stream (closes when the run ends); after and category filters apply |
POST /v1/runs/:id/stop | { id, stopped: boolean } |
POST /v1/runs/:id/steer | 202 { fromRunId, interrupted, run }; body input, message or guidance |
GET /v1/approvals, POST /v1/approvals/:id | Same as the /api/approvals pair |
GET /v1/capabilities | Which protocols and controls this server supports |
| POST /v1/runs field | Meaning |
|---|---|
model or agent_id | Required. An agent instance id, or a definition route that has exactly one instance. Foundry never creates an instance here. |
input or message | Required. A string or an array of Glove content parts. |
conversation_id | Conversation key. A key starting with control- (under 128 chars) is used verbatim; any other key maps to a stable control-<hash> id per instance; no key starts a new conversation. |
user | Fallback conversation key when conversation_id is absent. |
context | Object merged into request.context, plus protocol: "foundry-control". |
An x-foundry-conversation-id request header overrides both keys. The response header always carries the real conversation id; send it back to continue.
# 1. Start a run. 202 Accepted; the body is the FoundryRun.
curl -si http://127.0.0.1:4141/v1/runs \
-H 'content-type: application/json' \
-d '{"model":"support-acme","input":"Check every open order for delays.","conversation_id":"nightly-audit","context":{"trigger":"cron"}}'
# 2. Poll it.
curl -s http://127.0.0.1:4141/v1/runs/RUN_ID
# 3. Or follow it live; the stream closes after run.completed / run.failed / run.cancelled.
curl -N -H 'accept: text/event-stream' http://127.0.0.1:4141/v1/runs/RUN_ID/events
# 4. Change direction, or stop.
curl -s -X POST http://127.0.0.1:4141/v1/runs/RUN_ID/steer \
-H 'content-type: application/json' -d '{"input":"Only check orders placed this week."}'
curl -s -X POST http://127.0.0.1:4141/v1/runs/RUN_ID/stop{
"object": "foundry.capabilities",
"protocols": {
"native": true,
"openaiChatCompletions": { "streaming": true, "multimodalInput": true },
"openaiResponses": { "streaming": true, "multimodalInput": true },
"runControl": {
"create": true, "status": true, "events": true, "stop": true, "steer": true,
"steeringMode": "interrupt-and-restart"
},
"approvals": { "list": true, "resolve": true, "default": "deny" }
},
"runtime": { "ok": true, "execution": true, "environment": true, "activations": true, "agents": 1, "capabilities": 3, "surfaces": 0 }
}Request, run and result contracts
| FoundryRequest | Meaning |
|---|---|
agentId, conversationId, workspaceId | Required. Must belong together. |
message | FoundryMessageInput: a string or Glove ContentPart[]. |
payload? | Arbitrary JSON for your resolvers and run handler (ctx.request.payload). |
context? | Per-run key/values (ctx.request.context). The /v1 endpoints add protocol; steered runs carry steeredFromRunId and steeringMode. |
source? | { kind: "direct" | "transmission" | "activation" | "spawn" | "background", … }; send sets direct. |
| FoundryRun | Meaning |
|---|---|
id, agent | Run id and definition route. |
status | pending | running | completed | failed | cancelled |
input | The FoundryRequest. |
output? | FoundryResult once completed. |
agentId?, conversationId?, workspaceId? | Copied from the request. |
error?, attempts, maxAttempts, timeoutMs | Failure text and execution policy. |
createdAt, startedAt?, completedAt? | ISO timestamps. |
FoundryResult is { status: "completed" | "suspended", value, agentId, conversationId, workspaceId, suspension? }. For a normal model loop value is the final assistant message text; with a run or spawn handler it is whatever the handler returned, JSON-serialized. status is "suspended" with suspension: { commandId, wakeAt } when the agent put itself to sleep (see Playbooks, schedules & sleep).
Messages: Glove content parts
Foundry accepts exactly what Glove.processRequest accepts: a string, or an array of parts { type: "text" | "image" | "video" | "document", text?, name?, source?: { type: "base64" | "url", media_type, data?, url? } }. Media parts may carry a human-facing name. Inside the run, text parts are joined with newlines into the message text; a media-only message gets the text [multimodal message]. The whole history, including media, reaches lazy assembly as native Glove messages.
import { readFile } from "node:fs/promises";
// foundry, support and conversation as in the first example on this page
const invoice = (await readFile("invoice-1042.pdf")).toString("base64");
const handle = await foundry.send(support.id, conversation.id, [
{ type: "text", text: "The customer sent a photo of the box and the invoice." },
{
type: "image",
name: "damaged-box.jpg",
source: { type: "url", media_type: "image/jpeg", url: "https://example.com/damaged-box.jpg" },
},
{
type: "document",
name: "invoice-1042.pdf",
source: { type: "base64", media_type: "application/pdf", data: invoice },
},
]);JSON bodies are capped at 1 MB (1 048 576 bytes) by default. Set server.messageBodyBytes in foundry.config.ts (an integer from 1 048 576 to 134 217 728) to accept larger base64 media. The raised limit applies only to message-bearing endpoints: POST /api/conversations/:id/messages, /api/runs/:id/steer, /v1/chat/completions, /v1/responses, /v1/runs and /v1/runs/:id/steer. Everything else, including /api/agents/:route/runs, stays at 1 MB.
Custom run handlers
Give a route deterministic behaviour — an order-status lookup, an ingestion step, a wrapper that post-processes the model's answer — by setting run on defineAgent (or exporting run from a convention module). It runs after lazy assembly with the assembled runnable and a handler context; its return value becomes run.output.value. Without a model, the agent is model-free: calling the standard loop throws, so the handler must not.
import { defineAgent } from "glove-foundry";
import { z } from "zod";
const Payload = z.object({ orderId: z.string() });
export default defineAgent({
description: "Deterministic order-status endpoint; no model is configured",
run: async (_agent, { input, emit }) => {
// input is the FoundryRequest; payload arrives untouched from send(..., { payload }).
const { orderId } = Payload.parse(input.payload);
const status = orderId === "1042" ? "shipped" : "processing"; // your code: query the order system
emit({ type: "order-status.checked", data: { orderId, status } });
return { orderId, status }; // becomes run.output.value
},
});Call it like any other agent: foundry.send(agent.id, conversation.id, "status", { payload: { orderId: "1042" } }). To keep the model and add logic around it, call ctx.defaultRun(message?) (the normal Glove loop) inside run and return what you like. The full field list is on defineAgent & assembly.
Embedding the runtime
glove foundry dev and start do this for you. For tests or a custom host process, both classes are public exports of glove-foundry:
import { resolve } from "node:path";
import { FoundryRuntime, FoundryServer } from "glove-foundry";
import application from "../foundry.application.js";
import config from "../foundry.config.js";
const rootDir = process.cwd();
const runtime = await FoundryRuntime.discover({
rootDir,
agentsDir: resolve(rootDir, "agents"),
application,
// Lets agent processes load the application's conversationStore and data adapter.
applicationFilePath: resolve(rootDir, "foundry.application.ts"),
config,
});
await runtime.start();
const server = new FoundryServer(runtime, {
host: "127.0.0.1",
port: 4141, // 0 picks a free port
messageBodyBytes: 8 * 1024 * 1024,
});
const { url } = await server.listen();
console.log(`Foundry listening on ${url}`);
// The runtime exposes the same operations in-process.
const agent = await runtime.createAgent("support", { workspaceId: "acme" });
const conversation = await runtime.createConversation(agent.id);
const run = await runtime.send(agent.id, conversation.id, "Where is order 1042?");
const done = await runtime.waitForRun(run.id, { timeoutMs: 120_000 });
console.log(done?.status, done?.output);
await server.close();
await runtime.stop();FoundryServerOptions is { host?, port?, branding?, messageBodyBytes? }. listen() resolves { host, port, url } and throws on a non-loopback host without requestAuthorization. Agent turns always run in a separate managed execution daemon, not in this process; see Browser & sandbox for the execution model.