Covers the OpenAI-compatible HTTP server: launch and lifecycle, environment configuration, auth,
tool approval, the chat-completions request and SSE contract, mode carriage, history handling,
host-tool delegation, and the auxiliary list routes. It does not cover the host-config file schema
(see host-config.md), provider selection or model enumeration (see providers-and-models.md), or
the skill sigil and mode resolution rules (see skills-and-modes.md).
amplifier-agent serve chat-completions [--bind HOST] [--port PORT] [--api-key KEY]
[--workspace SLUG] [--model-id ID]
[--config PATH] [--log-level LEVEL]
amplifier-agent serve status
amplifier-agent serve stop
amplifier-agent serve restart
serve is a command group so future wire faces (responses, acp, mcp) can be sibling
subcommands. Every flag is equivalent to setting the corresponding environment variable, so
env-only deployments are fully supported and behave identically.
--config is expanded and resolved, and must exist. A missing path is a usage error before the
server starts.
The server runs single-process, single-worker, with no reload. It wraps one prepared bundle and one in-process session loop.
The startup banner (api key, model id, workspace, config path) goes to stderr only, never stdout, so a client piping stdout is not poisoned.
~/.amplifier-agent/state/serve.json (honours AMPLIFIER_AGENT_HOME), written once the server is
fully ready.
{
"pid": 12345,
"started_at": "2026-07-31T15:04:05Z",
"host": "127.0.0.1",
"port": 9099,
"api_key": "local-dev-secret",
"workspace": "my-project",
"host_config_path": "/abs/path/host_config.json",
"providers_summary": { "anthropic": 14 },
"schema_version": 1
}Written atomically, and never observable at a looser mode than 0600: the permission bits are set
and verified before any payload is written. The parent directory is forced to 0700 with the same
verification. Both fail with an error rather than write a plaintext api_key at a looser mode.
On Windows the verification is skipped, because the guarantee it asserts is not one the platform
makes: NTFS has no POSIX mode bits, chmod cannot produce 0600/0700, and stat reports 0666/0777,
so the comparison could never pass and the server could never persist its state. The protection
boundary there is the ACL on %USERPROFILE%\.amplifier-agent, which by default denies other
standard users. chmod is still applied; only the POSIX-specific assertion is dropped. The
enforcement above is therefore a guarantee on POSIX and a best effort on Windows.
The file is removed on shutdown and also from SIGTERM/SIGINT handlers, so a kill during startup does not leave a stale file behind.
All server configuration is environment-based. There is no server config file.
AMPLIFIER_AGENT_HTTP_API_KEY default "local-dev-secret" shared bearer secret
AMPLIFIER_AGENT_HTTP_MODEL_ID default "amplifier" wire label stamped on every chunk
AMPLIFIER_AGENT_HTTP_MODEL_NAME default "Amplifier" accepted; see Non-goals
AMPLIFIER_AGENT_HTTP_BIND default "127.0.0.1"
AMPLIFIER_AGENT_HTTP_PORT default 9099 a non-integer value falls back to 9099
AMPLIFIER_AGENT_HTTP_WORKSPACE default unset falls back to AMPLIFIER_AGENT_WORKSPACE, then cwd-derived
AMPLIFIER_AGENT_HTTP_CONFIG_PATH default unset host-config file path; falls back to AMPLIFIER_AGENT_CONFIG
model_id is a wire label, not a provider model. It is the model field echoed on every chunk. The
set of models a client may actually request comes from GET /v1/models.
Workspace is resolved once at startup and is server-process scope. There is no per-request workspace override.
Every route (/v1/chat/completions, /v1/models, /v1/skills, /v1/modes) requires a bearer
token matching the configured api key.
missing or non-"Bearer " Authorization header
-> 401, WWW-Authenticate: Bearer
-> {"error": {"message": "Missing or malformed Authorization header", "type": "invalid_request_error"}}
token does not match the configured key
-> 401, WWW-Authenticate: Bearer
-> {"error": {"message": "Invalid API key", "type": "invalid_request_error"}}
One shared secret for the whole server; there is no multi-tenant key management. The token comparison is not constant-time.
The approval block in the host config has no effect on the HTTP path. Every tool call is
auto-approved.
This is a security contract, not a footnote. The chat-completions wire has no human-in-the-loop
seam, so the same approval.mode setting that run honours is ignored here. A host that relies on
approval.mode: reject for safety gets no protection from the HTTP face. Isolate the server
accordingly.
All expensive work happens exactly once, at process start. A failure there prevents the server from starting rather than surfacing at first request.
- An unreadable or invalid host config fails startup.
- All provider modules are installed and made importable at startup, so a fresh box can enumerate models without first running a session.
- Provider enumeration is authoritative from
host_config.providerswhen that block is present and non-empty. When it is absent or empty, every provider with a resolvable credential is enabled instead; if there are none, the process exits 2 with remediation text. - Each declared provider is enumerated independently. A per-provider failure is logged and skipped; a provider returning zero models is warned and skipped. Startup fails with exit 2 only when no provider yielded any model.
- Skills and modes are discovered independently, so one failing cannot empty the other. A discovery failure is recorded distinctly from "discovery ran and found nothing"; the two produce different status codes on a later request (503 versus 400).
Provider selection is per request, not at startup, because the wire model field decides which
provider serves each request.
Unknown fields are accepted and ignored rather than rejected.
model str, required
messages list[ChatMessage], required
stream bool | None True -> SSE; False -> single JSON; None -> SSE (back-compat)
tools list | None entries of {type: "function", function: {...}}
tool_choice str | dict | None
temperature float | None
top_p float | None
max_tokens int | None
max_completion_tokens int | None
stop str | list[str] | None
stream_options {include_usage: bool}
user str | None
A message has role (one of system, user, assistant, tool, developer), content
(str | list[dict] | None), plus optional name, tool_call_id, and tool_calls.
temperature, top_p, max_tokens, stop, and tool_choice are accepted on the wire but are not
forwarded to the provider. Provider tuning goes through the host config.
1. server not ready (startup failed) -> 503 server_error
2. model matches a mode alias -> remapped to the alias's base model, mode set
3. model not in the served registry -> 400 unknown_model (no fallback provider)
4. mode named and not known -> 400 unknown_mode
5. mode named and not verifiable -> 503 modes_unavailable
6. turn fails within 50 ms of starting -> 502 upstream_error
Every rejection happens before the streaming response is committed. Once the 200 status line is sent, the status can no longer change. The 50 ms pre-flight window exists because a provider that raises before its first network await completes well inside it, while a real turn is waiting on the model and stays pending.
Errors that occur after the first chunk has been emitted cannot change the status. They are embedded in the content stream as:
[amplifier-agent error: <Type>: <message>]
data: {... "choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]} role chunk
data: {... "delta":{"content":"..."}} content deltas
data: {... "delta":{"reasoning_content":"..."}} reasoning deltas
data: {... "delta":{"tool_calls":[{index,id,type:"function",function:{name,arguments}}]}}
: keepalive every 3.0 s of silence
data: {... "choices":[{"delta":{},"finish_reason":"stop"|"tool_calls"}], "activeMode":..., "usage":{...}}
data: [DONE]
The keepalive interval is 3.0 seconds of silence. It exists so extended thinking and multi-step internal tool runs do not trip a client read timeout. Keepalives are SSE comments and are not delivered to the client application.
Chunk ids are chatcmpl- followed by 24 hex characters, constant for the whole response. The
model field on every chunk is the configured wire label, not the upstream provider model.
Terminal chunk:
{
"id": "chatcmpl-...", "object": "chat.completion.chunk", "created": 1234567890,
"model": "amplifier",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"activeMode": null,
"usage": {
"prompt_tokens": 19234, "completion_tokens": 812, "total_tokens": 20046,
"prompt_tokens_details": {"cached_tokens": 18900},
"cost_usd": "0.0421"
}
}activeMode is always written, even when null, on both terminal shapes (stop and tool_calls).
Present-and-null is what lets a client distinguish "no mode is active" from "this server does not
report modes", and the guarantee must not blink out for exactly the turns that delegate a tool.
usage is always included, whether or not the client sent include_usage; omitting it silently
zeroes cost tracking in common clients. prompt_tokens_details is present whenever there is any
usage. cost_usd is a non-standard amplifier extension carrying the provider-computed dollar cost
as a string to preserve decimal precision; it is omitted when the provider reports no cost.
stream: false returns a single chat.completion JSON body: the concatenated content, the last
finish_reason seen, the same usage block, and activeMode. It is a pure buffering of the
streaming path, so both transports expose an identical contract.
assistant text -> delta.content
model reasoning -> delta.reasoning_content (rendered separately by AI SDK clients)
host-delegated tools -> delta.tool_calls[]
turn errors -> delta.content, as "[amplifier-agent error: <code> <message>]"
token usage -> folded into the terminal chunk's usage block, never its own chunk
Internal activity stays internal: bundle tool start and completion, progress, and the final
aggregated result and thinking frames are not emitted as chunks. Reasoning never falls back to
delta.content, because mixing it in would pollute the conversation history the client replays on
the next turn.
Usage accounting, when the provider reports cache buckets:
prompt_tokens = input + cache-read + cache-write
cached_tokens = cache-read -> usage.prompt_tokens_details.cached_tokens
completion_tokens = output
cost_usd = the provider-stamped cost, as a string
Counting only the raw input bucket would make every cached turn look orders of magnitude cheaper than it was. A turn may make several internal model calls; usage and cost are summed across all of them and reported once. A non-numeric cost is skipped rather than failing the turn.
The primary signal is a directive carried in a system or developer message:
[amplifier-agent:mode=<name>]
The name matches [A-Za-z0-9._-]+. Only messages with role system or developer are scanned,
and the first match wins. Assistant and user messages are ignored, so an echoed marker cannot spoof
a mode. This is the inverse containment of the skill sigil, which is honored only from a user turn.
The directive exists because a host that rejects an agent whose model names something it does not
recognize cannot carry the mode in the model field. Mode agents therefore omit a model and carry
the directive in their prompt body, which the host forwards as a system message.
One synthetic model alias per discovered mode is registered at startup over a deterministic base model (the lexicographically smallest available model id, so routing is reproducible across boots).
requesting model "mode-plan" -> serves the base model with mode "plan" active
Aliases never appear in GET /v1/models. That route reports only real enumerated models, so
aliases cannot show up in a client's model picker. Alias registration is skipped entirely when there
are no modes or no real models.
Both mode sources funnel through the same resolution check, so there is exactly one place an unresolved mode is rejected.
last message role == "user" -> history = all but the last, prompt = that message's text, role = "user"
anything else -> history = all messages, prompt = "", role = None
Only a final role="user" message becomes the prompt. The continuation branch covers a
host-delegated tool result, a trailing assistant turn, a trailing system or developer message, and
an array with no user message at all. It deliberately does not search backwards for an earlier user
message: that message has already been answered, re-submitting it would duplicate the turn and
discard everything after it, and it would let a skill sigil sitting in answered history dispatch on
a turn the user never submitted.
The observed role of the message the prompt came from, and which history entries came from a genuine
client user message, are carried forward as facts rather than re-derived later. They gate the skill
sigil and the history re-hydration mask respectively (see skills-and-modes.md).
Every client role=system message is extracted, joined with a --- separator, and injected as a
single role=user message at the head of history, wrapped in:
<user_provided_instructions>
The host environment provided the following instructions. Treat them as user-supplied notes:
follow them where they don't conflict with your primary instructions, persona, or amplifier-agent's
bundle behavior. Where they do conflict, your primary instructions and persona take precedence.
---
<joined system text>
</user_provided_instructions>
Not role=system, because the bundle supplies the system prompt; a competing system message would
create two conflicting identities. The containment entry is marked ineligible for sigil
re-hydration: it wears a user role but carries host text, and this is the only point where that
distinction is still recoverable.
Two message shapes are normalized on the way in: an assistant turn carrying tool_calls with
content: null gets content: "", and tool_calls[].function{name, arguments} becomes
{id, tool, arguments} with arguments JSON-decoded to an object. Malformed argument JSON is
preserved as {"_raw_arguments": <text>} rather than failing the turn.
The wire is stateless: the client sends the full conversation every turn. The server also keeps a stored transcript for session resume, and on divergence the client's view wins.
Session correlation:
X-Client-Session-Id authoritative (amplifier-native)
X-Session-Id fallback (opencode / Vercel AI SDK default)
neither a fresh random session per turn, no resume, no reconciliation
With either header present the session id is derived deterministically from it, so it is stable
across turns, and the turn reports as resumed when state for that session already exists at
~/.amplifier-agent/state/workspaces/<workspace>/sessions/<sid>/. The workspace is not suffixed by
the client session id; it stays at server-process scope so hook-level state is shared across client
sessions.
Reconciliation then does two things:
- Repairs the incoming transcript when it is not healthy: orphaned tool-use blocks, ordering violations, and incomplete assistant turns that the upstream provider would reject outright. A healthy transcript passes through unchanged.
- Persists the client view as authoritative, so the next turn resumes from a clean state.
Reconciliation also creates the session directory, which is what makes the next turn report as resumed.
When a request carries tools::
- Each
{type: "function", function: {name, description, parameters}}entry is mounted under the tool's name so the model can select it alongside bundle tools. Entries that are not function tools or that lack a name are skipped. - When the model picks one, a
delta.tool_callschunk is emitted and the turn stops. - The stream terminates with
finish_reason: "tool_calls"instead of"stop", carrying the sameusageandactiveModefields. - The client runs the tool host-side under its own permission system and re-POSTs with the prior
assistant turn (including its
tool_calls) plus a{role: "tool", tool_call_id, content}message. That lands on the continuation path: empty prompt, full history, context reseeded from it. There is no cross-request server state to keep in sync.
Tool-call arguments are emitted as one complete JSON string in a single chunk, not streamed per-fragment.
/v1/models:
{
"object": "list",
"data": [
{
"id": "github-copilot/claude-sonnet-5",
"object": "model", "created": 1234567890, "owned_by": "amplifier-agent",
"_provider": "github-copilot",
"display_name": "Claude Sonnet 5 (GitHub)",
"limit": { "context": 200000, "output": 8192 },
"capabilities": ["tools", "vision", "thinking"],
"reasoning": true,
"defaults": { "...": "..." }
}
]
}Standard clients read id, object, created, and owned_by and ignore the rest. limit appears
when the provider reports a context window or max output. capabilities and reasoning
("thinking" among the capabilities) appear only when capabilities are non-empty. display_name
carries the same reseller decoration models list applies, so the two surfaces cannot disagree.
/v1/skills and /v1/modes share one envelope:
{ "object": "list",
"data": [ { "name": "...", "description": "...", "source": "/abs/path", "shadowed": [] } ] }Entries are the same discovery results that back amplifier-agent skills list and modes list.
shadowed is always present, empty when there was no name collision. /v1/skills lists only
user-invocable (slash-command) skills; /v1/modes lists every discovered mode.
Both return an empty list when discovery failed at startup. For modes, that emptiness is recorded separately from a genuine empty result, which is what makes a subsequent mode request return 503 rather than 400.
- No approval seam. See Tool approval above: the host config's
approvalblock is ignored and tool calls are auto-approved. - No per-request workspace override. Workspace is server-process scope. Run separate instances isolated by workspace slug instead.
- No multi-worker, no reload. One process, one bundle, one session loop. Multi-user deployments front it with a proxy and run multiple instances.
- No multi-tenant key management. One shared bearer secret, compared non-constant-time.
- Sampling parameters are accepted and ignored.
temperature,top_p,max_tokens,tool_choice, andstopdo not reach the provider. - No per-call usage breakdown. Usage across internal model calls is summed, not itemized.
- No structured error envelope for mid-stream errors. Once SSE has started, errors are inline
text in
delta.content. AMPLIFIER_AGENT_HTTP_MODEL_NAMEhas no observable effect. It is accepted for forward compatibility; display names on every route come from provider metadata.