How do I plug the claude CLI into a FastAPI code-generation endpoint?¶
You already have Claude Code working on your machine. This is how you put it behind an HTTP endpoint and drive it from your own code, without issuing anybody an API key.
When you'd want this¶
You have the claude CLI
installed locally and already logged in — claude login set up your
OAuth. You want a small HTTP endpoint that runs Claude inside an
agentkit run, with tool access and permission modes, without managing
API keys, provider quotas, or a second SDK.
ClaudeCliCognition subprocesses the local CLI once per run, streams
its stream-JSON output as agentkit StreamEvents, and hands you back
the usual AgentResult — usage, session id, cost estimate.
Reach for this when:
- You want local-first code-gen against
~/.claudeOAuth instead of distributing API keys to your server. - You need the CLI's built-in tools (
Read,Bash,Edit, ...) and its permission modes (acceptEdits,plan,bypassPermissions). - You want the CLI's cost estimate surfaced on
Usage.cost_usdautomatically.
Working code¶
"""Streaming code-gen endpoint over the local `claude` CLI.
Run: `uv run --with fastapi --with uvicorn uvicorn app:app`
Then: `curl -N -X POST localhost:8000/generate -d 'a python quicksort'`
"""
from __future__ import annotations
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from agentkit.agents import Agent
from agentkit.core.contracts.types import Scope
from agentkit.models.providers.cli.claude import ClaudeCliCognition
from agentkit.core.runtime import RunContext, Services
app = FastAPI()
# One cognition, shared across requests. It's stateless — every drive
# spawns a fresh subprocess. The class-level semaphore (`max_concurrent`)
# bounds parallel spawns so we don't hit the SDK's ~200-parallel hang.
cognition = ClaudeCliCognition(
model="claude-opus-4-5",
permission_mode="acceptEdits",
working_dir=Path("/tmp/codegen-sandbox"),
# Isolate CLI settings/auth per deployment. Concurrent writes to
# ~/.claude.json can corrupt it — a dedicated `config_dir` avoids that.
config_dir=Path("/var/lib/mysvc/claude"),
allowed_tools=("Read", "Write", "Edit", "Bash"),
max_concurrent=8,
)
agent = Agent(
name="codegen",
prompt="You are a terse code generator. Produce runnable code only.",
cognition=cognition,
)
@app.post("/generate")
async def generate(request: Request) -> StreamingResponse:
prompt = (await request.body()).decode() or "hello, world"
async def sse_stream():
# Minimal RunContext — no persistence, no tracing, just a run id.
ctx = RunContext(correlation_id="req-1", scope=Scope(), services=Services())
async for ev in agent.stream(prompt, ctx):
if ev.type == "message_delta":
yield f"data: {ev.text}\n\n"
elif ev.type == "tool_call":
yield f"event: tool_call\ndata: {ev.tool_call.name}\n\n"
elif ev.type == "final":
yield (
f"event: done\n"
f"data: session_id={ev.result.evals.get('session_id', '')}"
f" cost_usd={ev.result.usage.cost_usd:.4f}\n\n"
)
return StreamingResponse(sse_stream(), media_type="text/event-stream")
Every request spawns one claude -p "<prompt>" --output-format
stream-json --verbose subprocess with your model, system prompt,
allowed tool list, permission mode, and working directory threaded
through. Auth is entirely the CLI's problem: whatever
CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY, or
~/.claude/-stored OAuth the CLI would find on its own is what your
endpoint uses.
How it works¶
ClaudeCliCognition.drive(...) maps the CLI's stream-JSON output onto
the standard StreamEvent stream:
| CLI event | agentkit event |
|---|---|
{"type": "assistant", ...text} |
StreamEvent("message_delta") |
{"type": "assistant", ...tool_use} |
StreamEvent("tool_call") |
{"type": "user", ...tool_result} |
StreamEvent("tool_result") |
{"type": "result", ...} (final) |
StreamEvent("final", result=…) |
Cost, session id, and duration are lifted onto AgentResult:
final = await agent.run("write a REST client", ctx)
final.usage.cost_usd # CLI-side estimate (see below)
final.evals["session_id"] # pass back as resume_session_id= (NOT session_id=)
final.evals["cli_duration_ms"] # end-to-end CLI wall time
Typed output from the CLI¶
output= works here exactly as it does on any other agent. The schema goes
out as --json-schema, the CLI validates its own final answer against it
(re-prompting itself on a mismatch), and the validated value comes back
through the same SchemaAdapter as a real Python object:
class Invoice(BaseModel):
vendor: str
total: float
agent = Agent(name="extract", output=Invoice, cognition=ClaudeCliCognition())
result = await agent.run("Pull the vendor and total out of invoice.pdf", ctx)
result.parsed # Invoice(vendor='ACME', total=42.5)
result.evals["structured_output"] # the raw validated dict
Three outcomes, and only the first is a success — the other two set
partial=True and stop_reason="invalid_output":
| Outcome | evals["stop_reason"] |
|---|---|
| A conforming value | (absent — the run is clean) |
| The CLI exhausted its retries | error_max_structured_output_retries |
| Exit 0 but no value at all | structured_output_missing |
| A value the Python type rejects | structured_output_mismatch |
The last one is worth knowing: the CLI validates against JSON Schema, which
is looser than most Python types. evals["structured_output_error"] carries
the per-field diagnostics.
Pass json_schema= on the cognition to send a shape that isn't the agent's
Python type; it overrides output=.
Spend¶
The CLI bypasses the Invoker, so the meter() middleware never sees its
usage. That used to mean CLI spend was invisible: a $50 run against a $1
Budget completed happily and the ledger read $0.00. Both ends are wired
now.
ctx = RunContext("run-1", Scope(), services=Services(), budget=Budget(max_cost_usd=2.50))
result = await agent.run("Refactor the auth module", ctx)
ctx.budget.spent() # Decimal('0.750000') — what the CLI reported
ctx.budget.remaining() # Decimal('1.750000')
- Before the spawn, the run's remaining headroom goes out as
--max-budget-usd, so the CLI stops itself mid-flight instead of being audited after the money is gone. An already-exhausted budget doesn't spawn at all — the result carriesstop_reason="budget_exhausted", which is resumable: raise the ceiling and run again. - After the run, the reported cost and tokens are charged to every meter
on the context (
Budget, anyQuota) and toctx.actor_budget. - A ceiling crossed by this run is recorded in
evals["meter_error"], never raised — the money is spent and the answer exists; throwing it away helps nobody. meter_spend=Falseopts a run out of both ends (a warm-up call, an eval harness with its own accounting).
Usage.cost_usd remains a CLI-side estimate, so the ledger it feeds is an
estimate too.
One process, many turns¶
drive() spawns a subprocess per turn. That costs two to five seconds of CLI
warm-up every time — and the turns share no context, so a follow-up
question has no idea what was just discussed. Measured on the same two-turn
conversation:
| wall | turn 2 answers | |
|---|---|---|
session() |
9.7s | 8137 |
drive() ×2 |
16.1s | "I don't have a record of you asking me to remember a number" |
A session holds the process open and feeds turns over stdin, so the CLI keeps its own conversation context:
async with cognition.session() as chat:
async for ev in chat.turn("Summarise README.md"):
...
async for ev in chat.turn("Now list the risks you skipped"):
...
print(chat.session_id) # for a later --resume
A session is also Cognition-shaped, so it can be an agent's cognition and
consecutive agent.run(...) calls share one process and one conversation.
Every per-turn contract is unchanged — exactly one terminal final event, the
same stop reasons, the same metering — because both paths run the same
finaliser. What differs is what a shared process implies:
- Turns are serialised. One stdin and one transcript, so a second
concurrent
turn()waits rather than interleaving two conversations. - A dead process stays dead. If the CLI exits mid-session, that turn's
terminal result carries
stop_reason="failed"with the free-form detailevals["stop_reason"] == "session_closed", and later turns refuse rather than silently starting a fresh conversation with no history. - Cancelling a turn ends the session. No protocol message retracts a half-finished turn, so the process is terminated — better than a context holding half an answer nobody saw.
output=is not per-turn.--json-schemais fixed at spawn. Usedrive()for a typed run, or setjson_schema=on the cognition before opening the session; asking per turn is refused with that message rather than silently returning prose.
Stopping a turn without ending the session¶
Cancelling a turn terminates the process — no protocol message retracts a
half-finished turn, so the conversation ends with it. interrupt() is the
CLI's own verb for the same intent, and it keeps the session:
async with cognition.session() as chat:
stopping = None
async for ev in chat.turn("Refactor the whole module"):
if user_pressed_stop() and stopping is None:
stopping = asyncio.create_task(chat.interrupt()) # a SEPARATE task
receipt = await stopping
# Same process, same conversation.
async for ev in chat.turn("Actually, just do the imports"):
...
The interrupted turn still yields exactly one terminal final, with
stop_reason="interrupted" (terminated in the closed taxonomy — somebody
stopped it deliberately; nothing failed) and partial=True, because whatever
text arrived is a fragment.
receipt.still_queued names messages the CLI had queued and will run anyway —
the difference between "the agent stopped" and "the agent stopped and has three
more things to do". interrupt(cancel_queued=True) drops those as well, and is
refused up front if the CLI does not advertise the capability.
Call it from a separate task
The CLI acknowledges on the same stdout your turn() loop is draining, so
awaiting interrupt() inline in that loop suspends the only reader and
the acknowledgement cannot arrive. That degrades rather than deadlocks: the
receipt returns after ack_timeout_s with delivered=True and an error
explaining it, and the interrupt still takes effect because the write
already happened. A UI's stop button is naturally a separate task anyway.
Feature-detect with chat.capabilities / chat.supports("...") rather than
comparing version strings — the CLI advertises an open set on system/init
for exactly this.
Streaming tokens¶
By default the CLI emits one assistant message per completed content
block, so message_delta arrives per paragraph. For a UI with a cursor in it,
turn on partial messages:
Each provider token then arrives as its own message_delta. The completed
block still arrives too — the cognition uses it to accumulate
AgentResult.output and does not re-emit it, so a consumer sees each token
exactly once and the final text is written once. Thinking deltas stream the
same way and stay out of output (they land in evals["thinking"]).
Diagnostics in the result¶
result.evals["cli_init"] # model, mcp_servers, mcp_server_errors, plugin_errors, version
result.evals["api_retries"] # one entry per provider retry (also emitted live as `step` events)
result.evals["cli_duration_ms"]
mcp_server_errors and plugin_errors appear only when non-empty — the
CLI validates each --mcp-config entry, skips invalid ones and runs anyway,
exiting cleanly. Their presence is the signal, which makes them the CI gate
the CLI docs recommend:
if result.evals.get("cli_init", {}).get("mcp_server_errors"):
raise SystemExit("an MCP server did not load")
Human approval, without bypassing permissions¶
The CLI owns its own permissions, which left a service two options:
bypassPermissions (the agent may do anything, unattended) or dontAsk
(anything not pre-approved is denied outright and the run fails). agentkit
already had the missing middle — Asker, the injected human transport behind
its own HITL path — but nothing connected the two.
The CLI's seam is --permission-prompt-tool, which names an MCP tool it calls
instead of prompting a terminal. ApprovalServer is that tool:
from agentkit.integrations.mcp import ApprovalServer
async with ApprovalServer(asker=my_asker, auto_allow=("Read", "Glob"),
timeout_s=300) as approvals:
cognition = ClaudeCliCognition(
model="claude-sonnet-4-6",
**approvals.cli_kwargs(), # MCP, prompt tool, and ambient-permission isolation
)
result = await Agent(name="dev", cognition=cognition).run(task, ctx)
Each prompt becomes an Elicitation carrying the tool name and its
arguments — the path, the shell command — so your UI can show what is
actually being approved. The Decision maps back:
Decision.kind |
CLI behaviour |
|---|---|
approve / value |
runs with the arguments Claude requested |
modify |
runs with your arguments (updatedInput) — redirect a write into a sandbox without derailing the run |
deny |
blocked; note reaches the model, which may adapt |
expired |
blocked, with the deadline named |
Because an Asker may await a person indefinitely, the CLI turn parks in
place — same behaviour agentkit's own tool loop gets from the same protocol.
auto_allowexists to prevent habituation. The CLI prompts for reads too, and a person clicking yes on fortyReadcalls gives the fortieth prompt — the one that mattered — the same reflexive yes.timeout_sis enforced by the server, not trusted to theAsker: the protocol lets an implementation wait forever, and a queue worker holding a CLI subprocess open indefinitely is a resource leak with a model attached.- It fails closed. A broken transport, a timeout, an unexpected decision — all deny, with the reason attached. An approval gate that fails open is not a gate.
- The server binds loopback and requires a generated bearer token.
cli_kwargs()carries it, so nothing here assembles a header by hand. The token matters most in exactly this deployment: the point of the CLI is that it runsBash, and a build step, a package install or a script from the repository the agent was pointed at all execute in the same network namespace as the server holding the agent's approvals. Loopback alone assumes nothing untrusted shares the host, which is the assumption this worker exists to break. The credential is checked on HTTP requests and every other ASGI scope is refused outright.auth="none"restores the old behaviour by name.
Running it as a service¶
Four flags matter once this is behind an API rather than on your laptop.
ClaudeCliCognition(
bare=True, # --bare
stable_prompt_prefix=True, # --exclude-dynamic-system-prompt-sections
mcp_config=("/etc/mysvc/mcp.json",),
strict_mcp_config=True, # only the servers YOU declared
no_session_persistence=True, # nothing written to disk
fallback_model=("claude-sonnet-4-6",),
add_dirs=(Path("/srv/shared-templates"),),
settings='{"apiKeyHelper": "/usr/local/bin/fetch-key"}',
)
bare=Trueskips auto-discovery of hooks, skills, commands, subagents, plugins, MCP servers, auto memory andCLAUDE.md. Without it, a-psession runs the hooks in the working directory's.claude/settings.jsonand connects the servers in its.mcp.json— including a repository you just cloned to review. The CLI docs call bare "the recommended mode for scripted and SDK calls". It also never reads OAuth credentials or the keychain, so setANTHROPIC_API_KEY(or anapiKeyHelperinsettings); the cognition warns if neither is present, because the CLI's own error ("Not logged in · Please run /login") points at exactly the wrong fix on a machine whereclaudeworks fine interactively.stable_prompt_prefix=Truemoves per-machine sections (cwd, environment, memory paths) out of the system prompt and into the first user message, so the cache-stable prefix is byte-identical across users and machines running the same task. Documented for precisely this: "Use with-pfor scripted, multi-user workloads."strict_mcp_config=Trueignores every MCP configuration except the ones you passed. It's refused withoutmcp_config=, since on its own it would just leave the session with no servers.no_session_persistence=Truekeeps sessions off disk — a containment control in a multi-tenant service, not an optimisation.
Configuration walkthrough¶
ClaudeCliCognition(
claude_bin="claude", # override for a specific install path
model="claude-opus-4-5", # -> --model
working_dir=Path("/tmp/sandbox"), # subprocess cwd; --add-dir surface
config_dir=Path("/var/claude"), # CLAUDE_CONFIG_DIR — isolated auth
system_prompt_mode="append", # agent.prompt -> --append-system-prompt
tools=("Read", "Grep"), # -> --tools Read Grep (what EXISTS)
allowed_tools=("Read", "Grep"), # -> --allowed-tools (what runs unprompted)
disallowed_tools=("Bash",), # -> --disallowed-tools Bash
permission_mode="acceptEdits", # -> --permission-mode acceptEdits
max_turns=6, # -> --max-turns 6
resume_session_id=prior_evals["session_id"], # -> --resume (continue that run)
extra_args=("--fallback-model", "claude-sonnet-4-6"),
terminate_grace_s=5.0, # SIGTERM grace before SIGKILL
max_concurrent=8, # class-level BoundedSemaphore
)
What bites people¶
toolsandallowed_toolsare different flags.--toolsdecides which built-in tools the session has;--allowed-toolsdecides which run without a permission prompt. Listing three tools inallowed_toolsalone leaves every other tool — Bash included — available and merely prompting. If you mean a sandbox, settools=.agent.promptis APPENDED, not substituted.--system-promptreplaces Claude Code's entire system prompt, tool guidance included, so a one-line persona used to turn a capable coding agent into a chat model holding tools it no longer knew how to drive. Passsystem_prompt_mode="replace"if you genuinely want that.session_idnames a session;resume_session_idcontinues one.--session-idassigns a UUID to a new conversation. To pick up a previous run, pass itsevals["session_id"]asresume_session_id=. A non-UUIDsession_idis refused at construction.- Several config mistakes are refused at construction, not at spawn.
add_dirsentries that are not existing directories,tools=()(saytools=Noneto leave the CLI's set alone, ortools=('',)to disable every tool),strict_mcp_config=Truewith nomcp_config=,stable_prompt_prefix=Truewithsystem_prompt_mode="replace", and a non-UUIDsession_idall raiseValueErrorfrom__init__. Better there than three seconds into a subprocess. - Cold start is real. First subprocess per config_dir is 2–5s of
CLI warmup even before the model call. Second-and-later calls are
faster if you keep the same
config_dir. ~/.claude.jsoncorrupts under concurrent writes. The default~/.claudeis a single-writer store; running two production workers against the same home directory will eventually mangle it. Pointconfig_dir=Path(...)at a per-worker directory in production, and treat it as owned by the process.Usage.cost_usdis a CLI estimate. The CLI computes it from published per-token prices; provider invoices can drift from it. Surface it as an estimate in your UI, not a bill.agent.modelis ignored. OnlyClaudeCliCognition.modelis passed to--model. If both are set, the cognition wins; if neither is set, the CLI picks its default.ctx.check_cancelled()fires between events, not mid-event. On cancel the cognition sends SIGTERM (waitsterminate_grace_s) then SIGKILL, and still emits a terminalfinalwithpartial=Trueandevals["stop_reason"] == "cancelled". Long uninterruptible tool calls inside the CLI can only be killed, not "asked" to stop.
Related¶
- Concepts · Agents — how a
Cognitionslots into anAgent. - Human-in-the-loop tool approval — the agentkit-side gate that runs OUTSIDE this cognition (the CLI has its own permission model).
- Cap spend with Budget and Quota —
wire cost limits over the top of
ClaudeCliCognition.