Managoat is the hosted Fountain. Fountain is the open-source engine, and its name is on the CLI, the API, the SDK and this manual. Everything here applies to Managoat unless a page says it is for a self-hosted server.

API reference

Fountain serves a REST API. Each endpoint sits under /api/, and each one returns JSON.

Do you write a script, and not an integration?

The TypeScript SDK is one call over the conversation endpoints below, fountain.run(prompt, { agent, vault }).

Reach for the raw API when you need a surface the SDK does not wrap, or a language nobody wrote it in.

The authority is the OpenAPI spec that the server serves. It is always current.

  • GET /api/openapi.json, the OpenAPI 3.1 spec, generated from the code. It is public, and it needs no auth.
  • GET /api/docs, a Swagger UI over that same spec.

The endpoint lists below summarise that spec, for convenience. Each /api/ endpoint is in the spec, and the auth surface with them.

A test walks the router, and fails when somebody adds a route with no spec entry. So a generated client covers the whole API. It does not cover the parts that somebody remembered to document.

Authentication

An API key. Use this for a script and for CI.

Create a key under Account → API Keys. Or exchange your credentials at POST /api/auth/token, which is what fountain auth login does. Then pass the key as a Bearer token.

curl -H "Authorization: Bearer $FOUNTAIN_API_KEY" \
https://your-fountain.example.com/api/agents
POST /api/auth/token # email + password -> a fresh API key
GET /api/auth/me # identity of the authenticated user
POST /api/auth/api-keys # create a key (plaintext returned once)
DELETE /api/auth/api-keys/:id # revoke a key

Registration and account recovery. These need no auth. The token in the email authenticates the last step.

POST /api/auth/register # {email, password}
POST /api/auth/resend-verification
POST /api/auth/verify # {token} -> activate the account
POST /api/auth/forgot # {email}. Always 200, never reveals whether it exists
POST /api/auth/reset # {token, password}
POST /api/auth/email/confirm # {token}. Completes a pending email change

The verification and reset emails still link to the browser pages. These endpoints accept the same tokens, so a CLI can ask you to paste the code from your email, and open no browser.

verify is idempotent, so an account somebody already verified gets a 200. It issues no session, so mint a key at POST /api/auth/token once the account is live. A token failure is a 422, with an error of invalid_token or expired.

A reset that completes bumps session_version, and so does an email change that completes. That signs out each session that exists.

Credential changes. These need the bearer token and the current password. They take a full-scoped key alone.

POST /api/auth/password # {current_password, new_password}
POST /api/auth/email # {new_email, current_password}. Sends a confirmation link

A change of password signs out the browser sessions, because session_version bumps. It does not revoke an API key. Those are separate credentials, with their own expiries.

The response says so, in sessions_invalidated and api_keys_revoked. If you rotate because something leaked, revoke the keys yourself, with DELETE /api/auth/api-keys/:id.

The email endpoint answers the same way whether the address is free or not, so it tells nobody which addresses exist. The address changes only when somebody submits the emailed token to POST /api/auth/email/confirm.

A session cookie. You get one from OAuth at /auth/oauth/:provider, or from an email and password login. Fountain's own console uses it.

Sign in with Fountain (OAuth 2.0 for browser apps)

Fountain's own browser apps sit on other origins. Those are team and conversations, and neither pastes a key.

They use the authorization code grant with PKCE (S256), as public clients. The token they get is an API key (decisions/0021).

GET /oauth/authorize?client_id=…&redirect_uri=…&code_challenge=…&code_challenge_method=S256&state=…
# browser: consent page (login round-trips back here) → 302 redirect_uri?code=…&state=…
POST /api/oauth/token # {grant_type: "authorization_code", code, code_verifier, client_id, redirect_uri}
# → {access_token, token_type: "bearer", expires_in} (400 invalid_grant otherwise)
POST /api/oauth/revoke # bearer: revoke the presented token (sign-out)

You register a client on the server, in OAUTH_CLIENTS, with exact redirect URIs. A client or a redirect that nobody registered renders an error page, and Fountain redirects nowhere.

A code lives five minutes, and it works once. The key is full-scope, it expires in 30 days, and it lists under Account → API keys as oauth:<client_id>.

Account state

GET /api/account/onboarding # {state, completed, completed_at}
POST /api/account/onboarding/complete # idempotent

An account that somebody configured through the API alone never passes through the browser wizard. So nothing marks it onboarded, and a later browser visit re-enters that wizard. Complete it over the API and the loop closes.

GET /api/auth/me also carries email_verified, onboarding_state and onboarding_completed.

Billing

GET /api/account/billing # balance, cap, current-month usage
POST /api/account/billing/credits/checkout # Stripe Checkout URL for a credit pack

Stripe needs a browser to finish, so the URL is what you get. Mint it here, and open it there. The server chooses the return URLs. A URL that a caller supplied would be an open redirect.

credits/checkout returns a one-time Stripe Checkout URL for a credit pack. The balance moves when Stripe's webhook confirms the payment.

The checkout refuses a comped account with 422. It answers 502 when Stripe is unreachable, and guesses nothing. On an instance with payment off, both endpoints are a 404 with "billing": "disabled".

GET /api/account/billing reports the month as usage. The conversations count is the conversations that ran a turn in the month. The credit_burned_cents value is the credit the ledger took in the month, and turn_hours is the metered time. The two do not agree to the cent, because the pricer charges a turn when it ends.

GET /api/auth/me carries comped. It is true for an account an operator made free, and null when payment is off. It also carries brokered. It is true when the account runs behind the egress credential broker. See Secrets.

Data export and deletion

POST /api/account/exports # 202 with a pending export; 429 + Retry-After inside the hour
GET /api/account/exports # status (zero- or one-element list)
GET /api/account/exports/:id
GET /api/account/exports/:id/download # gzipped JSON
DELETE /api/account # {"confirm": "<your account email>"}. Irreversible

An export builds in the background, and the API has no PubSub. So poll GET /api/account/exports until downloadable is true. One account holds one export at most, and takes one request each hour.

To delete the account destroys the sandboxes, and removes each resource and the tenant encryption key. It needs the confirmation body, which is the API's version of the typed-email gate in the UI. It also needs a full-scoped key.

Inference credentials

A conversation runs on your own provider token, and Fountain never sees your inference traffic. So a new account cannot start a conversation until you set at least one.

GET /api/account/inference-credentials # per-provider set/not-set
PUT /api/account/inference-credentials/:provider # {"value": "...", "validate": true}
DELETE /api/account/inference-credentials/:provider # clear

The providers are anthropic_api_key, claude_code_oauth_token, openai_api_key and gemini_api_key.

PUT pings the provider to check the credential before it stores it. The outcomes differ, so a client knows whether to ask for the value again or to try again.

Status Meaning
200 Stored, encrypted under your tenant key.
422 invalid. The provider rejected it, and provider_status carries the upstream code. Or the value was blank. Or nobody knows that provider.
502 network. This instance could not reach the provider.
504 timeout. The provider did not answer in time.

Send {"validate": false} to store a credential with no ping.

A value is write-only, so these endpoints report which providers hold one, and no more. They need a full-scoped key, because the token a sandbox holds for one conversation can neither read nor replace the account's credentials.

Rate limiting

Fountain rate-limits a request by client IP, whether it carries auth or not. The limiter does not key on the API key. On a limit you get 429 Too Many Requests, with a Retry-After header.

Agents

GET /api/agents # list (?search=, ?runtime=, ?environment_id=, ?has_skills=, ?has_mcp=)
POST /api/agents # create
GET /api/agents/:id
PUT /api/agents/:id
DELETE /api/agents/:id
GET /api/agents/:id/avatar # image bytes (bearer token)
PUT /api/agents/:id/avatar # raw bytes with an image content-type, or {"data": base64, "media_type": ...}
DELETE /api/agents/:id/avatar

An avatar takes image/png, image/jpeg, image/gif or image/webp, up to 5 MB. The agent object carries avatar_media_type, which is null when there is no avatar.

Fountain refuses another type with 415, and an upload that is too large with 413. It serves the bytes with nosniff and a CSP that sandboxes them, and it checks the media type again at serve time.

An agent object carries conversation_count. An environment carries secret_count and agent_count. A vault carries secret_count.

An agent carries sandbox_mode, which is ephemeral or persistent. With ephemeral, each conversation gets a sandbox of its own. With persistent, the agent has one machine, and each conversation with the same environment and vault lands on it. That machine survives a conversation that ends, and nothing stops it while it runs. A launch can name the other mode with sandbox_mode on POST /api/conversations.

They are on the list read and on the single-resource read. So "does this environment have a user, and is it safe to delete" is one request.

Catalog

GET /api/catalog # runtimes, model suggestions per runtime, sandbox providers, package managers, avatar bases/moods, app URLs
POST /api/avatars/generate # {base, mood} → {data (base64 PNG), media_type}; attach with PUT /api/agents/:id/avatar

This is the vocabulary that builds the agent and environment forms. A client somewhere else can read it, and hard-code nothing.

A model list is a set of suggestions, and not an allowlist. Fountain accepts any provider/model under a provider it knows.

package_managers names what Fountain truly installs from an environment's packages, which is apt and npm. It stores another key and ignores it.

To draw an avatar, Fountain uses the tenant's own OpenAI credential. Without one it answers 422 no_openai_key.

apps is where this instance sends a person to read something. Those are the standalone conversations and team apps, which route on the fragment, as …/#/c/<conversation_id> and …/#/team/<agent_id>.

Either one is null where the deployment has no such app. Use apps, and do not compose a URL against the API host. Fountain's own UI is a console, and it serves no transcript.

Environments

GET /api/environments
POST /api/environments
GET /api/environments/:id
PUT /api/environments/:id
DELETE /api/environments/:id
GET /api/environments/:id/secrets # keys + timestamps only
POST /api/environments/:id/secrets # upsert
DELETE /api/environments/:id/secrets/:key

A secret value is write-only. Once Fountain stores it, the API never returns it. A list returns each secret's key, its id, and its timestamps.

Vaults

GET /api/vaults
POST /api/vaults
GET /api/vaults/:id
PUT /api/vaults/:id
DELETE /api/vaults/:id
GET /api/vaults/:id/secrets # keys, timestamps, expiry only
POST /api/vaults/:id/secrets
DELETE /api/vaults/:id/secrets/:key

The same write-only rule covers a vault secret value.

Secret bindings

Limited access. These routes answer only when the credential broker is on for the account. Read Feature status, and Where a secret comes from for what a binding does.

GET /api/secret-bindings # each binding: secret name, host, shape
GET /api/secret-bindings/presets # the shapes a binding can take
POST /api/secret-bindings
PATCH /api/secret-bindings/:id
DELETE /api/secret-bindings/:id

A binding is about the name of a secret, so it applies to every environment and vault that holds a secret of that name. A conversation-scoped token cannot reach these routes. They need a full-scope key.

Bulk apply

POST /api/apply # apply a compiled manifest in one request

This takes the compiled form of a fountain.yml manifest, which is what fountain apply sends.

{
"resources": [
{"kind": "Environment", "name": "proj", "spec": {"setup_script": "...", "secrets": {"TOKEN": "..."}}},
{"kind": "Vault", "name": "alice", "spec": {"secrets": {"GH": "..."}}},
{"kind": "Agent", "name": "researcher", "spec": {"model": "...", "runtime": "claude", "environment": "proj"}}
]
}

Fountain upserts a resource by name, in a fixed order. Environments first, then vaults, then agents. So an agent's spec.environment name reference resolves against an environment in the same manifest, or against one that already exists.

Fountain applies each resource on a best-effort basis. The response is always a 200, with one result for each resource. Each result carries an action of created, updated or error, and an error carries changeset-style errors. Each result also carries the outcome for each secret key. Fountain never echoes a secret value back.

Conversations

A conversation takes many turns. Create one with a first prompt, then prompt it again and again.

GET /api/conversations # list (?roots_only=true; ?agent_id= ?channel_id= ?status=idle,terminated)
POST /api/conversations # start (agent_id; optional vault_id, environment_id, sandbox_id, sandbox_mode, prompt, images)
GET /api/conversations/:id
DELETE /api/conversations/:id
POST /api/conversations/:id/read # clear unread state
POST /api/conversations/:id/requests/:request_id # answer a permission request ({option_id})
GET /api/conversations/:id/tree # the whole spawn tree this conversation belongs to
POST /api/conversations/:id/prompts # follow-up turn
POST /api/conversations/:id/interrupt # stop the running turn
POST /api/conversations/:id/terminate # end the conversation; destroys the sandbox unless it is persistent or shared
GET /api/conversations/:id/turns
GET /api/conversations/:id/events # log events as JSON (?streams= ?after= ?limit=)
GET /api/conversations/:id/stream # SSE log stream (?streams=stdout,stderr,stage ?wait=false)
GET /api/conversations/:id/turns/:turn_id/images/:position # image bytes
GET /api/conversations/:id/egress # what left the sandbox through the broker (limited access)

GET /api/conversations/:id/egress is there only when the credential broker is on for the account. Read Feature status. It lists each request that left the sandbox through the broker, with the host, the binding that matched, the status and the latency. A refused host shows the refusal. The list stays for BROKER_LOG_RETENTION_HOURS after the conversation ends.

A turn carries image_count. The image endpoint takes a position into that count, which starts at zero, and returns the raw bytes with the stored media type.

A turn also carries origin. It is user for a prompt that somebody sent. It is autonomous for a turn that the server opened for a background cycle of the agent, after the answer to its prompt. In that case prompt holds a marker, not the words of a person.

sandbox_id attaches the new conversation to a sandbox you already have. Fountain then provisions nothing. The sandbox must be ready or suspended, and Fountain must have built it for the same agent, environment and vault. Otherwise you get 404 sandbox_not_found, 409 sandbox_not_attachable or 422 sandbox_identity_mismatch. Several conversations can then run on one disk at the same time. On an opencode or gemini sandbox, only one turn runs at a time. A second prompt gets 409 sandbox_at_capacity while the first one runs.

sandbox_mode is ephemeral or persistent, and it replaces the agent's default for this conversation. A persistent conversation lands on the agent's own machine. Fountain makes that machine on the first persistent launch of an agent, environment and vault. A second launch while the first still builds it gets 503 provisioning, so send again shortly.

POST /api/conversations/:id/requests/:request_id answers a permission request that blocks the agent. The request and its options arrive as a permission_request block on the event stream. Send {option_id}, and choose one of the optionId values that the block carried. The first answer wins. A request that another client, the timeout or the end of the turn resolved gets 409 permission_request_resolved. An option the agent did not offer gets 422 unknown_option. A sandbox's own token cannot answer, and gets 403 sprite_may_not_answer.

Whatever does not resolve is a 404, so nobody can probe for an id.

That covers four cases. A conversation nobody knows. A turn from a different conversation. A position that is not there. A stored media type that is not an image.

The list takes three filters, and you can combine each of them with roots_only.

agent_id. channel_id, which names the channel that holds a conversation, and the team's is fountain:team. Remove a teammate and its conversation comes free, so it no longer matches. A teammate's full history is therefore GET /api/team/:agent_id/conversations.

status, which takes a comma-separated list. A value outside the vocabulary gives a 400 invalid_status.

The list has no pages.

A conversation object carries title, turn_count, last_active_at, last_read_at and a computed unread, next to the lifecycle fields.

unread is true when last_active_at is later than last_read_at, and for a conversation that nobody read. POST /api/conversations/:id/read clears it.

Token usage. Each turn carries usage, as {input, output, cache_read?, cache_write?}. That is the figure the runtime reports when the turn ends, in the usage of the ACP session/prompt response. claude-agent-acp and codex-acp report it.

It is null in three cases. While the turn runs. When the runtime reported none. On a turn older than the field.

Fountain records it once for each turn. It never sums the usage_update notifications that stream while a turn runs. Those report how full the context window is, and each runtime means something different by that.

Each conversation carries usage_total: {input, output}, a sum over its turns. A /api/team roster entry carries usage_total summed over each conversation the agent has had on the team.

/tree returns each conversation in the same spawn tree. Ancestors and descendants, flat, and each one with a parent_id.

{"data": [{"id": "…", "source": "ui", "status": "idle", "parent_id": null},
{"id": "…", "source": "agent", "status": "running", "parent_id": "…"}]}

A sub-conversation arrives over the API, with X-Fountain-Parent-Conversation-Id. So this is how an agent that fanned out lists what it started, and keeps no records of its own.

/events is the read model for the log feed, and /stream is the tail.

The JSON endpoint returns the rows that the stream sends, which are kind, stream, data, stage, state, duration_ms, turn_id and ts. It adds each event's id, and it returns the oldest first.

{"data": [{"id": 41, "kind": "output", "stream": "stdout", "data": "...", "ts": "..."}],
"meta": {"limit": 100, "has_more": true, "next_cursor": 41}}

To page, pass the previous response's meta.next_cursor as after. Continue while meta.has_more is true. limit defaults to 100, and caps at 1000.

The id is the value that the SSE route uses as Last-Event-ID. So a client can drain the history as JSON, then attach the stream where it stopped.

?blocks=true works on /events, on /stream and on /api/events/stream. It adds blocks to each event.

That is the event's data, parsed on the server into the structured blocks that a transcript renders. It is the parse the conversations app uses, Fountain.Conversations.Blocks. So a client never writes a runtime's dialect again. That is ADR 0014, applied to the wire.

Here are the kinds, and their fields.

kind fields
text, thinking body
tool_use id, name, summary, body, which is the input.
tool_result tool_id, body, error. Pair it with the tool_use that has the same id.
init summary, body
result body, raw
error body
raw body, summary. It is a line nobody recognised. Fountain shows it, and drops nothing.

An event that is not output carries blocks: []. Without the flag, the field is absent.

Every conversation on one stream

GET /api/events/stream # SSE (?streams= ?blocks=true)

This is one text/event-stream for each conversation the caller owns that has not finished. A conversation list with live status and unread dots needs that, and it needs no socket for each conversation.

Each event is the payload of the stream for one conversation, with conversation_id added.

Fountain sends a conversations event, {"reason":"changed"}, when the list changes. That is a create, a title, a read, a delete or a finish, and Fountain sends at most one each second. The stream follows a new conversation on its own, and the client lists them again.

Last-Event-ID replays what you missed, across each conversation the stream follows. The first byte is a : connected comment. A heartbeat arrives every 15 s. The stream closes after 60 s idle, so the client reconnects.

Sandboxes

A sandbox is the computer a conversation runs on. One sandbox can hold several conversations.

GET /api/sandboxes # list (?status=ready,suspended)
GET /api/sandboxes/:id
DELETE /api/sandboxes/:id # reset a persistent sandbox

Each sandbox carries status, mode, provider and url. It also carries the agent_id, environment_id and vault_id that Fountain built it for, and conversations. Each entry there carries mid_turn, which is true while that conversation runs a turn. To put a second conversation on a sandbox, pass its id as sandbox_id to POST /api/conversations.

DELETE resets a persistent sandbox. Fountain destroys the machine and keeps the conversations on it. The next prompt on one of them builds a clean machine for the same agent, environment and vault. The response is 204. An ephemeral sandbox, or one that is already terminated or failed, gets 422 sandbox_not_resettable. While a conversation on the sandbox runs a turn, the request gets 409 sandbox_mid_turn.

Three other requests retire a persistent sandbox, because each one moves the identity that the sandbox belongs to. A PATCH /api/agents/:id with a new environment_id retires the sandboxes built on the old environment. A DELETE on /api/environments/:id or on /api/vaults/:id retires the sandboxes built on that environment or that vault. Each request behaves like a reset. Fountain destroys the machine, keeps the conversations, and builds a new machine on the next prompt. A request also gets 409 sandbox_mid_turn while a conversation on one of those sandboxes runs a turn.

This is full-text search across the caller's conversations. A command palette uses it, to jump to a message.

GET /api/search?q=<text>[&limit=20][&offset=0][&agent_id=][&conversation_id=][&since=][&kinds=title,prompt,reply]
{"data": [{"kind": "reply", "conversation_id": "…", "agent_id": "…", "turn_id": "…",
"turn_number": 3, "snippet": "… the gate lives in the billing plug …",
"ts": "2026-08-19T02:00:00Z"}],
"meta": {"limit": 20, "offset": 0, "has_more": false}}

There are three sources, in one shape.

title is a conversation's title, and its turn_id is null. prompt is a turn's prompt. reply is a turn's assistant text, which is the text blocks of its events. Fountain materialises a reply when the turn ends, so you can search a turn in flight by its prompt, and not yet by its reply.

It is Postgres full-text, with websearch syntax. That is a "quoted phrase", a -excluded word, and or. It matches an exact token and stems nothing, so an identifier and a code fragment match as themselves.

Fountain ranks the hits, and puts the newest first among equals. The snippet is plain text with no markup. The ts is the creation time of the turn, or of the conversation itself. A limit caps at 100.

The query itself scopes each source to the caller. Fountain indexes nothing across tenants. The rate limit on the rest of /api covers this too.

A turn that ended before the reply_text column existed is searchable by its prompt alone. Run the one-time backfill on the server to fix that, with bin/fountain_server eval 'Fountain.Release.backfill_turn_replies()'.

Team

These routes serve the roster that /team shows, to a client that is not this web app. A teammate is a conversation bound to the reserved channel fountain:team.

Each route here wraps the Fountain.Team that the page wraps. So a standalone client gets the page's exact semantics, and writes none of them again over /api/conversations.

Those semantics are three. An add is idempotent. A message wakes a parked machine, or opens a fresh conversation when nobody can resume the old one. A remove terminates the conversation and unbinds it.

GET /api/team # roster: agent, conversation, presence, unread, preview
POST /api/team # add (agent_id; optional name, environment_id, vault_id)
GET /api/team/:agent_id
PATCH /api/team/:agent_id # rename ({name}; null or blank → the agent's name)
DELETE /api/team/:agent_id # remove: terminate the live conversation, unbind history
POST /api/team/:agent_id/messages # a turn (prompt; optional images) → {conversation_id}
GET /api/team/:agent_id/conversations # history: every conversation on the team, newest first, `current` flagged
POST /api/team/:agent_id/contact # give the teammate an email address + phone number ({prompt_from_number}; flag `team_comms`)
PATCH /api/team/:agent_id/contact # change prompt_from_number (nothing bought or released)
DELETE /api/team/:agent_id/contact # release them
GET /api/team/comms # {enabled, configured}: may this caller, and can this instance
GET /api/team/stream # SSE: every teammate's events on one connection (`?blocks=true`)

PATCH /api/team/:agent_id sets the teammate's name, which is its conversation's title. That name carries onto the fresh conversation that Fountain opens when nobody can resume this one. The audit trail records team.renamed, and the stream sends team.

GET /api/team/:agent_id/conversations is the teammate's history. A retired conversation is a previous machine's thread. It stays bound to the team channel until somebody removes the teammate, so the list shows it behind the current one, with current: false. Read it with GET /api/conversations/:id/events.

POST /api/team/:agent_id/conversations starts the teammate over, and keeps its machine.

Fountain retires the current conversation. It goes terminated, and the list shows it behind the new one with current: false. Fountain opens a new conversation on the same channel. That one carries the name, the environment and the vault. It points at the same sandbox.

Fountain provisions nothing and interrupts nothing. The next message wakes that sandbox through the usual reattach path, and starts a fresh runtime session on it. So the agent's context is new, and its files, clones and installed tools are where it left them.

You get a 201, with the teammate and its new conversation. You get 400 conversation_busy while a turn runs, so interrupt it first. You get 503 provisioning while the machine still starts.

When the machine has gone, Fountain provisions a new sandbox instead, exactly as POST /api/team would. The machine has gone when the sandbox is terminated or failed, or when nobody can resume the conversation.

The audit trail records team.conversation.rotated, and the stream sends team.

To retire the thread and the machine, the old way still works. Send POST /api/conversations/:id/terminate on the current conversation. The next message then opens a fresh one on a new sandbox.

POST /api/team answers 201 with the teammate. It answers 200 when the agent was on the team already, and returns that agent's live conversation and ignores the attributes you sent.

name becomes the conversation's title. environment_id and vault_id go through the agent's allowed_environment_ids and allowed_vault_ids, exactly as they do on POST /api/conversations. One that nobody knows, or that belongs to another tenant, is a 404. One the agent does not permit is a 422.

Each roster entry carries seven things. name, which is the title, or else the agent's name. The full agent object. The full conversation object. presence. unread. last_turn. preview, as {kind: "you"|"them"|"typing", text}, or null when there are no messages.

presence carries a state, one of working, starting, online, asleep, away, machine_offline, failed and offline. It carries a label for a person to read.

machine_offline is a teammate on a self-hosted runner whose machine is not connected. A message cannot wake it, and you get 503 runner_offline. It comes back when the daemon reconnects.

The conversation's sandbox carries provider. On a runner it also carries runner: {id, name, hostname, online, path}.

Email and phone. Alpha, behind the team_comms flag. Off by default on the hosted platform. Read Feature status.

POST /api/team/:agent_id/contact gives the teammate an inbox, from AgentMail, and a number, from AgentPhone. Both sit under the instance's own keys, and the roster entry gains contact: {email, phone}.

From its next turn, the teammate holds seven MCP tools. Those are email_send, email_reply, email_list, email_get, sms_send, sms_list and my_contact_info. Fountain serves them itself, at POST /api/mcp/team-comms/:conversation_id, with the conversation's sprite token. So no provider key enters the sandbox. The audit trail records each send as team.contact.sent, and never the content.

There are five refusals. You get 404 team_comms_not_enabled when the flag is off for the caller. You get 503 team_comms_not_configured when the instance holds no keys. You get 409 contact_already_provisioned for a second contact. You get 402 contact_limit_reached when the account holds as many contacts as TEAM_CONTACT_CEILING permits, and the body carries count and limit. You get 424 provider_error when a provider refuses, where channel names which one. A provision is all or nothing.

DELETE releases both upstream, then forgets them. GET /api/team/comms answers {enabled, configured}, so a client knows whether to offer it. Read configuration.

The request body must carry prompt_from_number. Any common format works, and Fountain stores E.164. It is your phone.

A text from that number to the teammate's number arrives as a prompt in the teammate's conversation. AgentPhone delivers it to POST /api/webhooks/agentphone, and AGENTPHONE_WEBHOOK_SECRET verifies the HMAC. Fountain wraps it, so the teammate knows it came by SMS and can answer with its sms_send tool.

Fountain acknowledges a text from any other number, and ignores it. It deduplicates a delivery by AgentPhone's X-Webhook-ID. The audit trail records team.contact.prompted, with the byte count, and never the text.

A STOP from that number opts it out, and so does UNSUBSCRIBE, CANCEL, END or QUIT. Fountain sets contact.prompt_opted_out_at, and drops that number's texts until a START, or until somebody changes the number, which is fresh consent. Fountain answers a HELP.

Each keyword gets a confirmation, texted back from the teammate's number, on a best-effort basis.

/messages returns 202 {status: "queued", conversation_id}. The id names the conversation the message went to. That is a new conversation when somebody terminated the teammate's previous one.

You get 400 conversation_busy while the last turn still runs. You get 503 provisioning while the machine starts. You get 503 runner_offline while the machine behind a runner-backed teammate is off.

Teammates know each other. Each conversation on the team channel carries an MCP server, fountain-team. Fountain serves it at POST /api/mcp/team/:conversation_id, and the sandbox's own token authenticates the call. It holds four tools.

list_teammates.

get_teammate, by name, by role or by keyword, and "the engineer" resolves.

send_to_teammate. The message lands in their thread, with the sender named in front. It carries a note that the owner and their teammates alone can send such a message.

A teammate that is busy, that still starts, or whose machine is off comes back as a tool error, so the agent can try again. The tool returns the turn it created.

wait_for_teammate, which blocks for up to 90 s for the reply. since_turn pins it to one turn. A timed_out: true means call it again.

read_teammate, which returns the recent prompts and replies.

So "send this to the steward", inside one teammate's turn, is two tool calls. The team page shows the exchange in both threads.

/stream is one text/event-stream for the whole team. Each event is the payload of the stream for one conversation, with conversation_id and agent_id added. A client routes it to a roster row, and needs no socket for each teammate.

Fountain sends a team event, {"reason":"changed"}, when the roster changes. Three things change it. Somebody adds or removes a teammate. Fountain opens a fresh conversation for one. A self-hosted runner connects or drops, which changes presence for the teammates on it.

The stream then follows the new conversation itself, and the client lists them again.

The stream honours Last-Event-ID, replayed across each teammate, and it honours ?streams=. A heartbeat arrives every 15 s. It closes after 60 s idle, so the client reconnects.

Schedules

These are the routines that the team page offers, over the API. A routine is a cron that runs a teammate with a prompt. It runs in the teammate's own thread, or on a one-off machine.

Each route wraps Fountain.Team.Schedules, so a standalone client gets the page's exact semantics.

GET /api/team/schedules # every schedule of the caller, soonest first
GET /api/team/:agent_id/schedules
POST /api/team/:agent_id/schedules # cron (5 fields, UTC), prompt; optional name, one_off, enabled
GET /api/team/:agent_id/schedules/:id
PATCH /api/team/:agent_id/schedules/:id
DELETE /api/team/:agent_id/schedules/:id
POST /api/team/:agent_id/schedules/:id/run # run now → 202 {status: "queued", conversation_id}

A schedule carries id, agent_id, name, cron, prompt, one_off, enabled, next_run_at, last_run_at, last_conversation_id and last_error.

Fountain evaluates cron in UTC. A 0 9 * * 1-5 is 09:00 UTC on a weekday. A name in the @daily style works, and @reboot does not.

one_off: false is the default. It sends the prompt into the teammate's own conversation, as a typed message would.

one_off: true opens a fresh conversation on a new machine for each run. It uses the teammate's agent, environment and vault.

The agent must be the caller's. It need not be on the team yet.

A schedule has the address (agent_id, id). The same row under another agent's path is a 404, as another tenant's row is.

/run takes the path that the page's "Run now" takes, and it answers as /messages does.

You get 400 conversation_busy while the teammate's previous turn still runs. You get 503 while its machine starts. You get 404 when the agent of an in-thread schedule is not on the team. Fountain stamps last_run_at and last_error either way.

The audit trail records a create, an update, a delete and a run, as team.schedule.*, with the request's attribution.

The team stream sends a schedule event, {"reason":"changed"}, whenever anybody creates, updates, deletes or fires a schedule. That holds for the API, for the page and for the scheduler. So a client lists them again, and polls nothing.

A browser client on another origin needs API_CORS_ORIGINS set on the server. Read configuration. A bearer key is the one credential that crosses an origin.

Support

This is "Report a problem" from a client (#843). Fountain stores the report with whatever context the client attaches, then forwards it to the operator out of band.

That context is the conversation, the agent, the sandbox, the presence, the recent events, the app version and the page URL. Those are the facts that triage needs, and never a secret.

POST /api/support/reports # {category, message, context?, client?, screenshot?} → 201 the report
GET /api/support/reports # the caller's reports, newest first
GET /api/support/reports/:id

The category is one of bug, stuck, question, idea and other. The message takes up to 20 KB. The context is an object of up to 64 KB. The client is a string of a name and a version.

screenshot takes the {data: base64, media_type} shape that a prompt image takes. It accepts png, jpeg, gif and webp, up to 5 MB.

The response carries status, which goes new and then forwarded or failed. It carries forwarded_at, and external_url, which names the GitHub issue when Fountain opened one, and forward_error. It reports that a screenshot is there, and never inlines it.

The audit trail records support.report.created, with the category, the sizes and the context keys. It never records the message.

An Oban job forwards the report. It opens a GitHub issue when the instance sets SUPPORT_GITHUB_REPO and SUPPORT_GITHUB_TOKEN, which needs issues:write, and it labels the issue support and the category. It mails SUPPORT_EMAIL, with the screenshot attached. It can do one, the other, or both.

Neither one configured is fine, because the rows are the inbox. The rate limit on the rest of /api covers this too.

Admin

These are for an operator account, with role: "admin", that holds a full-scoped key.

Each action mirrors the admin UI, and its refusals with it. Each one records the same privilege-trail event.

GET /api/admin/users # ?q= ?comped= ?role= ?verified= ?sort= ?dir= ?page= ?per_page=
GET /api/admin/users/:id
POST /api/admin/users/:id/role # {"role": "admin"|"user"}
POST /api/admin/users/:id/sandbox-limit # {"limit": n}
POST /api/admin/users/:id/comp # {"comped": true|false}
POST /api/admin/users/:id/suspend # {"suspended": true|false}
POST /api/admin/users/:id/credits # {"cents": n, "note": "..."}: grant credit
DELETE /api/admin/users/:id
GET /api/admin/sandboxes
POST /api/admin/sandboxes/:id/reap
GET /api/admin/audit # cross-tenant audit events
GET /api/admin/events # the privilege trail: who did what to whom

Here are the refusals. You cannot suspend your own account, delete it, or change its role. Use another admin, or DELETE /api/account to delete your own.

The payment actions are 404 with "billing": "disabled" on an instance with payment off. Those actions are comp and credits.

A read across tenants returns metadata alone. Prompt content and output content never cross a tenant boundary, whatever the role.

Webhooks

GET /api/webhooks # list endpoints
POST /api/webhooks # create one; the secret is in this response only
GET /api/webhooks/:id
PATCH /api/webhooks/:id # url, description, event_types, status
DELETE /api/webhooks/:id
POST /api/webhooks/:id/rotate-secret # a new secret; the old one stops verifying
POST /api/webhooks/:id/test # queue a signed `webhook.test` event
GET /api/webhooks/:id/deliveries # ?limit= (default 50, max 200)
POST /api/webhooks/:id/deliveries/:delivery_id/redeliver

Fountain sends conversation lifecycle transitions to a URL you own. An integration that cannot hold a socket open does not have to poll. The payload carries ids, a stage and a duration, and never conversation content.

These routes need a full-scope key. A sandbox's per-conversation token must not point the account's events at a URL that the sandbox picks.

The webhooks reference holds the full event catalogue, the payload shape, a worked signature verifier and the at-least-once contract.

Audit

GET /api/audit # ?limit= ?before= ?action_prefix= ?resource_type= ?since= ?until=

This is the account's own append-only trail, newest first. Each row carries id, inserted_at, actor, action, resource_type, resource_id, metadata and request_ip.

actor tells four things apart. ui is a browser session. api is a bearer key. sprite is the token a sandbox holds for one conversation. system is Fountain itself.

To page backwards, pass meta.next_cursor as before. limit defaults to 100, and caps at 500.

action_prefix matches a family of actions, such as vault.. Fountain treats it as a literal, and not as a LIKE pattern.

since and until take ISO 8601 timestamps. Fountain refuses a malformed one with a 400, and ignores none of them without a sound.

You see this tenant's events, and no other tenant's.

The /audit page in the browser takes the same four filters, as ?action=, ?resource=, ?since= and ?until=. It runs them through the same query, so a filtered view is a link you can share.

That page shows the newest 200 matches, and it has no pages. Use this endpoint to walk the whole trail.

Error responses

{"error": "not_found", "message": "Agent not found"}
Status Meaning
400 The request body is invalid.
401 The auth is absent or invalid.
402 Your credit balance is zero or below. The code is insufficient_credits, and the body carries upgrade_url. The old subscription_required code does not occur. A teammate contact past the account's ceiling is contact_limit_reached.
403 The wrong tenant.
404 Nothing found.
409 The request conflicts with the current state. The codes are no_runner_online, sandbox_at_capacity, sandbox_not_attachable, sandbox_mid_turn, permission_request_resolved and contact_already_provisioned.
410 Somebody terminated the conversation. The code is conversation_terminated. Stop, and do not try again.
422 A validation error.
429 The rate limit stopped you.
500 An internal error.
503 The instance is at its fleet ceiling, or a sandbox is not up yet. The codes are fleet_full, provisioning and sprite_probe_failed, and the response carries Retry-After.

LLM-native discovery

  • /llms.txt, a short API summary.
  • /llms-full.txt, the full API reference.
  • /skill, a drop-in skill for Claude Code, Cursor, Continue and Aider.

Read LLM integration for the detail.