A deployable agent harness. Point it at a source holding an AI playbook —
.cursor/, .claude/, or a plain AGENTS.md — and it provisions an isolated
workspace, discovers the instructions, rules, skills and subagents inside, and
runs a full tool loop against any model LiteLLM can reach. It is an HTTP
microservice, so one deployment serves many callers, and a paused run can be
resumed by a different replica.
Python 3.11 · FastAPI · Postgres · Apache-2.0
Designed and built by Joseph Benraz
- What makes it different
- Quick start
- Security model
- How it works
- Features
- How it compares
- API reference
- Configuration
- Workspaces, input and durable output
- Running many instances
- Development
- Limitations and open work
- Documentation
- License
Agent harnesses usually ship as a CLI or an IDE feature that runs on one developer's machine. This one is a service, which changes what you can build on it:
- The playbook is an input, not a build artifact. Callers hand over a source
location in the
folderfield: a git URL, an HTTP(S) URL to a.zip/.tar.gzarchive — including a blob or bucket object, provided it points straight at the file — or a path the service can reach. The service detects which layout it is looking at, discovers the primitives inside — rules, skills, commands and subagents — and composes the system prompt from what it finds;initiatereturns that catalog. Nothing is inlined by the caller and nothing is baked into the image. - Pauses survive the process. When a run needs a human — a UI tool, the
built-in
ask_user, or a hook asking permission — it persists its state to Postgres and returns. Any replica can resume it. There are no sticky sessions and no in-process waiters. - Human interaction is a protocol, not a prompt. The service speaks AG-UI over SSE, streams tokens and tool calls, and accepts batch interrupt resolution, so a UI can resolve or cancel several open interrupts in one request.
- The UI can push its own tools. Frontend tool catalogs are supplied per run, so many applications can share one deployment without clobbering each other's tool lists.
- Many MCP servers, unambiguous names. Every tool is suffixed with the name
of the server that provides it (
search_google,weather_general), so collisions across servers are impossible and every call is traceable. - Provider-neutral. Model choice is a per-request string resolved by LiteLLM. Nothing in the harness is tied to one vendor.
Prerequisites: Docker Desktop, or — if you bring your own infrastructure — Postgres 16+ and a LiteLLM endpoint reachable from the service. At least one MCP server is needed either way.
Configuration is per environment: ENV selects the directory under src/.env/,
and that directory's .env is the only one loaded. Docker and local runs
therefore read different files, so follow one path below, not both.
The shared stack deploys what the agent depends on — Postgres, LiteLLM and Ollama — as its own Compose project, so the agent joins infrastructure instead of carrying it. Copy the compose-layer example, then start both:
./start-shared-mac.sh # macOS: Ollama stays on the host for the Metal GPU
./start-agent-mac.sh # or ./start-agent-mac.sh --ensure-shared to do bothOn Windows use start-shared-windows.bat and start-agent-windows.bat; there
Ollama runs in a container and the default model is pulled for you.
There is no file to copy first. On their first run the launchers create both
git-ignored environment files from the tracked examples, and generate this
machine's LITELLM_MASTER_KEY and POSTGRES_PASSWORD into .env. Nothing is
overwritten afterwards, so the credentials keep matching the data volumes
already created from them, and no two clones share a password. Running
docker compose directly instead of the launchers fails with the name of the
variable to set rather than falling back to a well-known default.
The launcher never deploys infrastructure. It joins a running jb-ai-shared
stack, and if that is not up it stops and prints the command to run.
Since the agent then reaches services by name, docker-compose.stack.yml
overrides DATABASE_URL, LITELLM_BASE_URL and LITELLM_API_KEY; every other
setting still comes from src/.env/docker/.env.
Two environment files, two jobs: .env at the root configures the stack itself
and is git-ignored, while src/.env/docker/.env configures the agent process.
The container sets ENV=docker, and Compose loads src/.env/docker/.env. Edit
that file — it already points at host.docker.internal to reach Postgres,
LiteLLM and MCP servers on your host:
DATABASE_URL=postgresql+asyncpg://postgres:<password>@host.docker.internal:5432/pyagent
LITELLM_BASE_URL=http://host.docker.internal:4000
LITELLM_API_KEY=<your-litellm-key>
MCP_SERVER_URLS=[{"name": "local", "url": "http://host.docker.internal:8001/mcp"}]docker compose up --build -d./start-agent-mac.sh --no-shared (or start-agent-windows.bat --no-shared) runs
exactly this, skipping shared-stack detection — the mode to use when Postgres and
LiteLLM are managed elsewhere.
Only
src/.env/docker/.env.exampleis tracked. The launchers copy it tosrc/.env/docker/.env, which is git-ignored and is where your own values belong. Standalone mode reaches Postgres and LiteLLM directly, so you must replace the__POSTGRES_PASSWORD__and__LITELLM_API_KEY__placeholders — the service refuses to start while any placeholder remains, naming the variables it found. The shared-stack path supplies both for you.
Create the localhost environment file from the tracked example and fill in the
values marked CHANGE_ME:
cp src/.env/localhost/.env.example src/.env/localhost/.envDATABASE_URL=postgresql+asyncpg://postgres:<password>@localhost:5432/pyagent
LITELLM_BASE_URL=http://localhost:4000
LITELLM_API_KEY=<your-litellm-key>
MCP_SERVER_URLS=[{"name": "local", "url": "http://localhost:8001/mcp"}]pip install -r src/requirements.txt
ENV=localhost alembic upgrade head
cd src && ENV=localhost python -m app.mainRun alembic from the repository root, where alembic.ini lives; ENV tells it
which src/.env/<env>/.env to read DATABASE_URL from. On Windows PowerShell,
set $env:ENV='localhost' once, then run alembic upgrade head and
cd src; python -m app.main.
Either way, DATABASE_URL must use the postgresql+asyncpg:// scheme, not plain
postgresql://, and MCP_SERVER_URLS must be an array of {name, url} objects.
A bare list of URL strings is ignored rather than rejected, so unless you also set
the numbered MCP_SERVER_URL_{n} variables, startup fails with no servers
configured. MCP servers below covers each malformed shape.
curl http://localhost:8000/isaliveInteractive API docs are at http://localhost:8000/swagger,
unless you set DOCS_ENABLED=false, which removes both /swagger and
/openapi.json. List the tools your MCP servers actually exposed, then run a
request:
curl http://localhost:8000/v1/agent/getToolscurl -X POST "http://localhost:8000/v1/agent/executeRequest" \
-H "Content-Type: application/json" \
-d '{
"task": "Search for technology updates and check the weather",
"tools": ["search_google", "weather_general"],
"model": "gpt-4o"
}'Tool names are whatever your servers expose, suffixed with the server name — replace the
toolsabove with names from/getTools. Omittoolsentirely to let the agent use everything available.
The service authenticates no one, so what you just started is deliberately the least capable version of it. Each item below is off until you turn it on, and Security model explains what changes when you do.
| Out of the box | To change it |
|---|---|
Every port binds to 127.0.0.1, so nothing is reachable from the network |
BIND_ADDR=0.0.0.0, once something in front of it authenticates callers |
| No browser origin can call the API; requests from a page are refused | CORS_ALLOWED_ORIGINS=https://your.app |
/swagger and /openapi.json are served |
DOCS_ENABLED=false |
execute_local is absent — the agent cannot run shell commands |
LOCAL_SHELL_ENABLED=true, ideally with SHELL_COMMAND_ALLOWLIST naming what may run |
| Workspace hooks do not run | HOOKS_ENABLED=true, remembering that hooks are shell commands the bound workspace supplies |
LITELLM_MASTER_KEY and POSTGRES_PASSWORD are generated on this machine's first launcher run |
supply your own in .env before that first run |
Two of those the Docker quickstart relaxes for you, both confined to this
machine. It sets ALLOW_INPLACE_WORKSPACE=true so a bind-mounted folder can be
edited in place, with WORKSPACE_ALLOWED_ROOTS restricting that to the folder
you mounted — startup refuses in-place access with an empty allowlist. And it
lists http://localhost:5173 and http://127.0.0.1:5173 in
CORS_ALLOWED_ORIGINS, the origins the bundled ag-ui-demo
runs on, so the demo works without editing configuration first. Both are
relaxations of src/.env/docker/.env.example only; the code defaults stay
closed, so anything not built from that file starts with no origin allowed.
Remove both from your copy for any deployment that is not the local demo.
Skipping the launchers and running docker compose directly is supported, but
then nothing has generated the secrets: Compose stops and names the variable to
set rather than falling back to a default that would be identical everywhere.
The container image runs alembic upgrade head before serving, which is why the
Docker path above has no migration step. Running outside Docker you apply them
yourself, as in the local sequence above:
ENV=localhost alembic upgrade headStartup also calls init_db(), which creates the database if it is absent and
issues create_all, so an empty database works without a manual step. The
migrations additionally adopt databases that were first created by create_all.
See docs/ORCHESTRATOR.md §12 for migration and backfill
detail, and §13 for the schema itself.
This service authenticates no one. Every endpoint is open to anyone who can reach the port. That is deliberate — it is a component designed to sit behind a gateway that authenticates callers, terminates TLS and enforces tenancy — but it means the deployment topology is the security boundary, and the defaults assume you have not built that gateway yet.
So the shipped defaults are closed. Published Compose ports bind to
127.0.0.1, no cross-origin browser call is accepted until you name an origin,
and the two capabilities that execute commands — the local shell and
workspace-supplied hooks — are off until you turn them on:
| Setting | Default |
|---|---|
BIND_ADDR |
127.0.0.1 |
CORS_ALLOWED_ORIGINS |
empty — no cross-origin call accepted |
LOCAL_SHELL_ENABLED |
false |
HOOKS_ENABLED |
false |
OUTPUT_BINDINGS_ENABLED |
false |
DOCS_ENABLED |
true — set false off your own machine |
Startup refuses three configurations outright rather than running them: a
wildcard CORS origin combined with credentials, in-place workspaces with no
WORKSPACE_ALLOWED_ROOTS to confine them, and any value still holding an
unreplaced __PLACEHOLDER__.
The one thing to internalise before pointing this at anything real: a playbook is executable input. The rules, skills and hooks found in a bound workspace shape the system prompt, and hooks run as shell commands. Binding a workspace you do not trust is equivalent to running its code, and no harness — this one included — solves prompt injection. Constrain what a run can reach instead of relying on the model to refuse.
SECURITY.md has the hardening checklist for a real deployment, the full threat model, and how to report a vulnerability privately.
flowchart LR
A[Caller] -->|folder + prompt| B[Bind workspace]
B --> C[Scan playbook<br/>AGENTS.md · rules · skill catalog]
C --> D[Compose system prompt]
D --> E[Tool loop]
E -->|local · MCP · frontend tools| E
E -->|needs a human| F[(Persist state<br/>Postgres)]
F -->|resume, any replica| E
E -->|SSE tokens + tool calls| A
A run binds a workspace, scans it once per segment, composes an authoritative
system prompt from the discovered instructions plus a lazily-loaded catalog of
skills, commands and subagents, then loops: the model picks tools, the service
executes them, oversized results are offloaded to .agent/offload/, and older
history is summarized and compacted as the context grows. If a tool needs user
input the loop persists and returns rather than blocking.
docs/ORCHESTRATOR.md is the source of truth for every
step.
A few words carry precise meanings throughout the API, and the distinctions matter when reading the lifecycle rules:
| Term | Meaning |
|---|---|
| Playbook | The instructions a caller points the service at: a .cursor/ or .claude/ directory, or a plain AGENTS.md. It is input, never baked into the image. |
| Session | A bound folder you can execute against repeatedly. Created by initiate, identified by orchestratorGuid. A thread is the AG-UI equivalent. |
| Run | One request executing against a session. Recorded as an ExecutionRun so a failed attempt is visible without disabling the session it belonged to. |
| Segment | The bounded slice of a run between two pauses — one tool loop plus its model calls, governed by RUN_SEGMENT_DEADLINE_SEC. A run that pauses for input resumes as a new segment, possibly on a different replica. |
| Interrupt | A pause needing a human: a frontend tool, the built-in ask_user, or a hook asking permission. Surfaces as awaitsResponse with a stateGuid. |
| Binding | Where a run reads input and writes durable output. Input can be bound in place or copied into a sandbox; durable output survives the session. |
The harness-side concepts — adapters, primitives, loading policy, compaction — are defined in docs/ORCHESTRATOR.md §1.
- Harness discovery — Cursor, Claude Code and generic adapters; eager loading
of
AGENTS.md(including.cursor/AGENTS.md),CLAUDE.mdand always-on rules, with progressive disclosure for skills, commands and subagents. Project hooks run when enabled, and apermission: askhook surfaces as an interrupt. An adapter is two methods — score a detection, return a manifest — over shared primitive discovery, so covering another tool's layout is around a hundred lines rather than a new subsystem. - Built-in coding tools, namespaced
*_localon workspace-backed runs:list_files,read_file(text plus images and PDFs),write_file,edit_file,create_file,create_folder,glob,grep,execute,write_todos,taskfor subagents, git helpers andask_user. Binding durable output addswrite_output_local,edit_output_local,read_output_localandlist_output_local. - Context management — token streaming, oversized tool results offloaded to disk and referenced by handle, LLM summarization of older turns, and tool message compaction on a character or token budget.
- Human interaction — AG-UI SSE runs, per-run frontend tool catalogs,
canonical batch
resume[]with resolve and cancel, a fan-out event stream, and Postgres-persisted holds resumable from any replica. - Multiple MCP servers — named servers aggregated into one tool list with universal attribution, per-server fault isolation, and optional filtering of remote tools that collide with a built-in.
- Operational safety — path and shell allow/deny policy, workspace root restriction, size caps and clone timeouts, close endpoints with a 200/202 contract, run heartbeats and stale-run reconciliation.
The harness follows patterns established by Claude Code and LangChain Deep Agents: root instructions and always-on rules for stable context, progressive disclosure so skills cost a catalog entry until they are needed, subagents to keep detailed work out of the parent context, and filesystem tools plus summarization and large-result offload for long-running work. None of that is unique, and neither is connecting several MCP servers at once.
What differs is the delivery model:
| Claude Code | Cursor agent¹ | Deep Agents | This service | |
|---|---|---|---|---|
| Form factor | CLI / IDE session | IDE / local agent | Python library you embed | HTTP microservice |
| Instruction source | CLAUDE.md, skills |
.cursor/rules, AGENTS.md |
Code you write | Any bound folder, scanned per run |
| Who supplies the playbook | The developer at the keyboard | The developer at the keyboard | The application author | The caller, per request |
| Multiple MCP servers | Yes | Yes | Via adapters | Yes, with universal name attribution |
| Human-in-the-loop | Interactive prompts | In-editor approvals | Framework interrupts | AG-UI protocol over SSE, batch resolve/cancel |
| Resume after restart | Local session resume | Local session resume | Application-selected checkpointer or store | Built in: Postgres-backed, any replica, no sticky sessions |
| UI-supplied tools | — | — | Application-defined | Per-run frontend tool catalog |
| Model routing | Anthropic models | Multiple providers | Your choice | Any LiteLLM model, per request |
Durable resume is not the distinguishing part — Deep Agents runs on LangGraph, whose checkpointers and stores can be external and durable, and its hosted server persists state too. The difference is that here it is not something you assemble: the HTTP resume contract, the ownership rules that let a different replica claim a paused run, and the persistence behind them ship as the service's public behaviour.
¹ Cursor is an IDE product and this is a deployable service, so this compares
delivery models rather than feature parity — it consumes the same playbook
formats (.cursor/, AGENTS.md) that Cursor users already write. All four
projects move quickly; check their own documentation for current behaviour.
Interactive documentation is served at /swagger while DOCS_ENABLED is true,
which is the default; setting it false withdraws both /swagger and
/openapi.json.
| Method | Path | Purpose |
|---|---|---|
GET |
/isalive |
Health check |
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/agent/executeRequest |
Execute a request with optional MCP and AG-UI tools |
GET |
/v1/agent/getTools |
List available MCP tools (plus legacy globally-bound frontend tools) |
POST |
/v1/agent/resumeRun |
Resume a paused execution with an AG-UI tool result and optional model / max_tool_calls overrides |
GET |
/v1/agent/execution/{executionGuid} |
Latest execution status and result metadata |
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/orchestrator/initiate |
Bind a folder to a new session, provision the workspace, detect the orchestration type, return orchestratorGuid plus discovered agents and skills. No LLM call. |
POST |
/v1/orchestrator/execute |
Run a prompt against an initiated session. Returns the result, or awaitsResponse plus stateGuid when input is needed. Send the complete current frontendTools list: their names are named in the system prompt so the model knows an interaction tool exists. |
POST |
/v1/orchestrator/resume |
Continue an awaiting run with { orchestratorGuid, stateGuid, toolCallId, result } and optional model / maxToolCalls overrides. Pod-agnostic. |
GET |
/v1/orchestrator/{orchestratorGuid} |
Session and run status. While awaiting input, also replays canonical interrupts and pendingToolCallIds from persisted state so authorized callers can rebuild interaction UI. |
POST |
/v1/orchestrator/{orchestratorGuid}/close |
Close the session. 200 closed or 202 closing; retry after active runs finish or go stale. Removes service-owned runtime and workspace; never touches in-place caller input or durable output. |
| Method | Path | Purpose |
|---|---|---|
POST |
/api/ag-ui/run |
SSE run: bind frontendTools, stream output, receive tool-call callbacks |
GET |
/api/ag-ui/events |
Fan-out SSE stream of all tool-call events |
POST |
/api/ag-ui/threads/{threadId}/close |
Close thread lifecycle. 200 closed or 202 closing. Cancels holds and removes thread runtime and AG-UI-owned sandboxes only. |
POST |
/api/ag-ui/threads/{threadId}/abandon |
Idempotently discard active holds. Non-destructive; leaves runtime and workspaces in place. |
All three resume paths can select the model and tool budget for the new
segment. AG-UI and orchestrator requests use model / maxToolCalls; the
legacy direct-agent endpoint uses model / max_tool_calls. When omitted, a
persisted await snapshot remains authoritative, preserving existing behavior.
An explicit override is persisted if the segment awaits again, and switching
models or providers replays the existing history without translation.
/api/ag-ui/events emits the same TOOL_CALL_START, TOOL_CALL_ARGS and
TOOL_CALL_END payloads the AG-UI runtime produces, for every run rather than
one caller's. Multiple subscribers are supported, each holding its own SSE
connection and filtering client-side on toolCallName or toolCallId. This is
how a service that drives /v1/agent/executeRequest over plain HTTP can still
observe UI callbacks in real time.
POST /api/ag-ui/run accepts an optional workspacePath (plus inPlace) to
bind the built-in *_local tools to an existing directory. That requires
ALLOW_INPLACE_WORKSPACE=true, and WORKSPACE_ALLOWED_ROOTS can restrict which
paths are bindable. When a workspacePath is bound on a fresh run the folder is
scanned exactly as the orchestrator scans it, and its orchestration context plus
a lazy-load skill/command/agent catalog become the system prompt — callers hand
over the folder instead of inlining AGENTS.md. Project hooks and path/shell
policy apply when enabled. read_file_local on an image or PDF attaches
multimodal parts to the next turn if the model is vision-capable. Resume patches
the persisted run-binding block without re-scanning.
- Register frontend tools (optional).
POST /api/ag-ui/runwith athreadId, yourfrontendToolsand emptymessagesreturns a short bind-only stream (RunStarted+RunFinished). Interactive runs use thefrontendToolssent with each run, so this only refreshes the legacy global cache behind/v1/agent/getTools. - Subscribe to global events (optional).
GET /api/ag-ui/eventsfor a fan-out view of tool activity, useful for dashboards. - Start an interactive run.
POST /api/ag-ui/runwithmessages,threadId, an optionalrunId, and the currentfrontendTools. Keep the SSE stream open for Run/Tool/Text events. - Handle tool calls. On
TOOL_CALL_START+TOOL_CALL_ARGS, render the component described by the toolparametersschema. - Resolve interrupts. If a run ends with
RUN_FINISHED.outcome.type="interrupt", collect every open interrupt and resume withPOST /api/ag-ui/runcarryingresume: [{ "interruptId": "<interrupt.id>", "status": "resolved", "payload": ... }]. Usestatus: "cancelled"without a payload to cancel. Several entries may be resolved in one request.
Always copy interruptId from each Interrupt.id. Frontend-tool interrupts
normally reuse the originating tool_call_id; hook-permission interrupts carry
their own permission ID and reference the deferred tool ID in metadata. Legacy
state.toolResponse is still accepted but is not the preferred contract.
Include the complete current frontendTools list on every interactive run. If
they are absent, the model cannot see them. If tools explicitly names an AG-UI
tool that is not present, the request fails with a "Requested tools not found"
error.
stateDiagram-v2
[*] --> Running: execute
Running --> AwaitingResponse: a tool needs a human
AwaitingResponse --> Running: resume, on any replica
Running --> Completed: run finishes
Running --> Failed: segment fails
Failed --> Running: execute again
AwaitingResponse --> AwaitingResponse: claim goes stale, restored for retry
Completed --> [*]
A failed segment is recorded against that attempt and leaves the session
runnable — only an explicit close makes it terminal. That is why Failed above
returns to Running rather than ending the diagram.
When a tool pauses a run, /v1/agent/executeRequest responds with:
{
"awaitsResponse": true,
"executionGuid": "2f8e7c1b-...",
"stateGuid": "0ad6c9f0-...",
"agui_tool_calls": [
{
"tool_call_id": "call_123",
"tool_name": "Weather-Gate",
"arguments": {"city": "New York"}
}
],
"result": null
}agui_tool_calls[0].tool_call_id is the same ID emitted on the SSE stream.
Collect the user's answer, then either post it through AG-UI with resume[], or
call POST /v1/agent/resumeRun with {executionGuid, stateGuid, toolCallId, result/error} for a pure API flow. GET /v1/agent/execution/{executionGuid}
reports the latest status and the currently-waiting stateGuid. Every execution
ends completed or failed, and final responses are persisted, so dashboards
can poll without replaying the run.
A failed initiate, execute or resume may carry a stable errorCode
alongside the human-readable error. Provider failures use QUOTA,
RATE_LIMIT, AUTH or UNAVAILABLE; a segment that exhausts its tool budget
uses MAX_TOOL_CALLS. Raw provider response bodies are never returned.
Two codes describe a workspace whose root instructions (AGENTS.md /
CLAUDE.md) cannot be loaded. They are separate because they need different
fixes:
| Code | Meaning | Fix |
|---|---|---|
ROOT_INSTRUCTIONS_TOO_LARGE |
The file loaded but exceeds the eager budget | Split the file, or raise HARNESS_EAGER_BUDGET_CHARS |
ROOT_INSTRUCTIONS_UNREADABLE |
The file could not be read at all — permissions, I/O, or a path discovery refuses such as a symlink or a non-regular file | Check the file's permissions and type |
Both fail the call rather than silently running against partial or stale
context, and initiate and execute report the same code for the same
condition.
{
"role": "string (optional)",
"task": "string (required)",
"context": "string (optional)",
"outputInstruction": "string (optional)",
"tools": ["tool1", "tool2"],
"model": "gpt-4o",
"max_tool_calls": 10
}{
"success": true,
"response": "AI response content",
"error": null,
"tool_calls_made": 2,
"total_tokens": 150,
"partial_response": null,
"tool_calls_info": [
{
"tool_index": 1,
"tool_name": "google_search_general",
"llm_tool_interaction_index": 1,
"mcp_server_id": "general",
"mcp_server_url": "https://server1.com/mcp"
}
]
}GET /v1/agent/getTools returns each tool with its attributed name,
bracketed server description, input_schema, server_url, server_id, and
original_name.
toolsfilters MCP and AG-UI tools only. Workspace-backed*_localbuilt-ins are added independently.- Omitted or
nullselects all available MCP tools and the run's AG-UI tools. - An empty list selects no MCP or AG-UI tools.
- A non-empty list selects exactly those names; an unknown name is a bad-request error.
- Use the full attributed name (
search_google, notsearch). - One model turn may select several tools, including tools from different servers.
Two forms are supported, both taking {name, url} objects. The name becomes the
suffix on every tool from that server.
# Recommended: JSON array
MCP_SERVER_URLS=[{"name": "general", "url": "https://server1.com/mcp"}, {"name": "google", "url": "https://mcp.google.com/mcp"}]# Numbered objects, discovered from _1 upwards until the first gap
MCP_SERVER_URL_1={"name": "general", "url": "https://server1.com/mcp"}
MCP_SERVER_URL_2={"name": "google", "url": "https://mcp.google.com/mcp"}MCP_SERVER_URLS is preferred when both are set, but only if its first element
is an object — otherwise parsing falls through to the numbered variables. A blank
or missing name becomes default.
The two forms handle bad input differently, so it is worth knowing which one you are using:
| Input | Result |
|---|---|
| Numbered entry that is not an object with both keys | Skipped with a warning; discovery continues at the next index |
| Numbered index missing | Discovery stops there, so _1 and _3 yields only _1 |
Array entry missing url |
Kept, then rejected at startup: MCP server at index N must be an object with 'name' and 'url' properties |
| Array mixing objects and bare strings | Fails with AttributeError rather than a configuration error |
| Array of only bare strings | Ignored — falls through to the numbered variables, or leaves the list empty |
Empty array ([]) |
Ignored the same way, so the numbered variables decide |
MCP_SERVER_URL (no suffix) |
Never read |
Not supported: an array of bare URL strings (
MCP_SERVER_URLS=["https://a/mcp"]) and the single-serverMCP_SERVER_URL=...variable, both of which earlier documentation described as backward-compatible. With no numbered variables set, either leaves the server list empty and startup fails withAt least one MCP server URL must be configured.
Every tool is renamed {original_name}_{server_name} and its description is
prefixed [server_name], whether or not the base name collides. Original names
are preserved for the call to the server, and execution logs carry the server
name and URL. See
docs/MULTI_SERVER_IMPLEMENTATION.md.
| Variable | Default | Purpose |
|---|---|---|
DATABASE_URL |
— | Required. Async SQLAlchemy URL, e.g. postgresql+asyncpg://pyagent:pyagent@localhost:5432/pyagent |
LITELLM_BASE_URL |
http://localhost:4000 |
LiteLLM endpoint |
LITELLM_API_KEY |
— | Required. LiteLLM key. No default: it must match what your gateway expects, and a shipped one would be the same known credential everywhere. The launchers generate one for the bundled stack |
LITELLM_REQUEST_TIMEOUT_IN_SEC |
300 |
HTTP idle/network timeout for each LiteLLM request |
LITELLM_MODEL_DEADLINE_SEC |
240 |
Absolute wall-clock deadline for one complete LiteLLM call, including streamed response consumption |
LITELLM_MAX_COMPLETION_TOKENS |
0 (off) |
Optional operator resource guard; when positive, sends a completion-token cap and accepts possible truncation |
LITELLM_DROP_PARAMS |
True |
Drop parameters a model does not accept |
MAX_TOOL_CALLS |
10 |
Tool calls per request |
ServiceName |
jb-ai-orchestrator-service |
Service name |
Environment |
DEV |
Environment name |
Region |
USC1 |
Region |
SwaggerBasePath |
empty | Base path when served behind a gateway |
DOCS_ENABLED |
true |
Serves /swagger and /openapi.json. false withdraws both, leaving no route that describes the API |
CORS_ALLOWED_ORIGINS |
empty | Comma-separated origins allowed to call the API from a browser. Empty sends no CORS headers, so cross-origin calls are refused. * is accepted, but startup fails if combined with credentials |
CORS_ALLOW_CREDENTIALS |
false |
Whether cross-origin requests may carry cookies and authorization headers |
General_LogFolder |
./Logs |
Log directory |
Logging_LogLevel_Default |
Information |
Log level |
BIND_ADDR is not read by the service: it belongs to the Compose layer in
./.env and decides which host interface each published port binds to. It
defaults to 127.0.0.1 — see Security model.
| Variable | Default | Purpose |
|---|---|---|
HARNESS_EAGER_BUDGET_CHARS |
24000 |
Total characters of eagerly-injected context. Root AGENTS.md/CLAUDE.md are one mandatory allocation of this budget and fail explicitly when they exceed it, rather than being silently truncated; raise this instead of splitting a large playbook. Optional rules take what remains, whole or not at all. A non-numeric or non-positive value is logged and ignored. |
| Variable | Default | Purpose |
|---|---|---|
RESUME_CLAIM_TIMEOUT_SEC |
300 |
Age at which a stale in-progress resume claim is restored for retry |
RUN_HEARTBEAT_INTERVAL_SEC |
5 |
Heartbeat interval for active runs; must be ≥ 1 |
RUN_HEARTBEAT_STALE_SEC |
300 |
Runs without a newer heartbeat are stale and reconciled before close or a replacement claim; must exceed the interval |
RUN_SEGMENT_DEADLINE_SEC |
270 |
Absolute wall-clock deadline for workspace/binding preparation plus the complete model/tool loop |
RUN_CANCELLATION_WARN_SEC |
5 |
Emit a structured diagnostic if a cancelled worker has not stopped after this many seconds |
CLOSE_WAIT_TIMEOUT_SEC |
10 |
Wait before close returns 202 closing instead of 200 closed |
The default ordering is model call 240s, complete segment 270s, caller
300s, and reverse proxy 310s or more. A deadline cancels and awaits the
worker before its execution_runs claim becomes terminal. A new claim may
reconcile a crash-orphaned row only after its heartbeat is stale; a fresh row
still returns RUN_CONFLICT.
The service is a generic execution engine: it enforces containment, isolation, bounded execution, safe claim release, and retryable session state. It does not set workflow policy for model output or behavior. Failed attempt records remain diagnostic history, while the execution, pending interaction, or conversation returns to its real runnable state unless the caller explicitly closed it.
| Variable | Default | Purpose |
|---|---|---|
WORKSPACES_ROOT |
<tmp>/jb-agent-workspaces |
Root for per-execution sandboxes |
ALLOW_INPLACE_WORKSPACE |
false |
Allow inPlace=true to run on a local path without copying |
WORKSPACE_ALLOWED_ROOTS |
empty | Absolute roots bindable in place or via AG-UI workspacePath |
GIT_CLONE_TIMEOUT_SEC |
120 |
Timeout for git clone and archive downloads |
MAX_WORKSPACE_MB |
0 |
Soft cap on provisioned workspace size; 0 disables |
OUTPUT_BINDINGS_ENABLED |
false |
Allow non-null output bindings and durable output tools |
OUTPUT_READ_MAX_BYTES |
5242880 |
Durable output read cap |
OUTPUT_WRITE_MAX_BYTES |
5242880 |
Durable output write cap |
OUTPUT_LIST_MAX_ENTRIES |
1000 |
Durable output listing page cap |
AZURE_BLOB_TIMEOUT_SEC |
60 |
Per-operation Azure Blob timeout |
PROMPTS_DIR |
empty | Override directory for YAML prompt templates |
| Variable | Default | Purpose |
|---|---|---|
LOCAL_TOOLS_ENABLED |
true |
Expose the built-in local coding tools |
LOCAL_TOOLS_NAMESPACE |
local |
Suffix marking local tools, e.g. read_file_local |
FILTER_MCP_TOOLS_CONFLICTING_WITH_LOCAL |
true |
Drop remote MCP tools whose base name collides with a built-in |
LOCAL_SHELL_ENABLED |
false |
Expose execute_local. Off by default: the service authenticates no one, so this grants a shell to anyone who can reach the API |
LOCAL_SHELL_TIMEOUT_SEC |
60 |
Default shell timeout |
LOCAL_SHELL_MAX_OUTPUT_BYTES |
100000 |
Captured bytes per output stream |
SUBAGENT_ENABLED |
true |
Expose the task_local subagent tool |
SUBAGENT_MAX_TOOL_CALLS |
8 |
Tool calls inside one subagent |
SUBAGENT_MAX_DEPTH |
2 |
Maximum task_local nesting depth |
| Variable | Default | Purpose |
|---|---|---|
CONTEXT_COMPACTION_ENABLED |
true |
Offload oversized tool results and compact older tool messages |
TOOL_RESULT_OFFLOAD_CHARS |
8000 |
Offload a tool result above this JSON size |
CONTEXT_COMPACTION_CHARS |
120000 |
Compact when total message characters exceed this |
CONTEXT_COMPACTION_TOKENS |
0 |
When above 0, use a token budget instead of characters |
CONTEXT_COMPACTION_KEEP_RECENT_TOOL_MSGS |
8 |
Recent tool messages kept intact |
CONTEXT_COMPACTION_KEEP_RECENT_TURNS |
4 |
Recent turns kept during summarization |
CONTEXT_SUMMARIZATION_ENABLED |
true |
Summarize older turns before tool compaction |
CONTEXT_SUMMARIZATION_MODEL |
run model | Model used for summarization |
LLM_STREAMING_ENABLED |
true |
Stream tokens to AG-UI when a UI channel exists |
| Variable | Default | Purpose |
|---|---|---|
HOOKS_ENABLED |
false |
Run project Cursor/Claude hooks. Off by default: hooks are shell commands supplied by the bound workspace |
HOOKS_FAIL_CLOSED |
false |
Block the action when a hook errors or times out |
HOOKS_TIMEOUT_SEC |
30 |
Per-hook subprocess timeout |
HOOKS_MAX_OUTPUT_BYTES |
100000 |
Cap on hook output |
PATH_POLICY_ENABLED |
true |
Enforce path and shell allow/deny lists |
PATH_DENYLIST / PATH_ALLOWLIST |
empty | Comma-separated globs under the workspace |
SHELL_COMMAND_DENYLIST / SHELL_COMMAND_ALLOWLIST |
empty | Regex filters for execute_local |
MULTIMODAL_ENABLED |
true |
Attach images and PDFs read by read_file_local |
MULTIMODAL_MAX_BYTES |
5242880 |
Maximum attachment size |
MULTIMODAL_MODEL_ALLOWLIST |
empty | When set, only matching model ids receive attachments |
MULTIMODAL_MODEL_DENYLIST |
gpt-3.5-turbo, embeddings, … |
Model ids that never receive attachments |
MULTIMODAL_VISION_MARKERS |
gpt-4o, Claude, Gemini, … |
Vision-capable markers used when no allowlist is set |
Hook configuration keeps Cursor-first precedence, followed by Claude
hooks.json and settings.json. Files are opened within the workspace without
following symlinks and are limited to 64,000 characters. Unsafe, malformed,
excessively nested, or oversized hook configuration is ignored and no hooks
from it execute; discovery reports the reason in manifest notes. Primitive
discovery also refuses symlinks and reports one manifest note with the number of
unique symlinked workspace paths skipped during that collection.
Full orchestrator and harness knobs are documented in
docs/ORCHESTRATOR.md §11, and every key above appears in
src/.env/localhost/.env.example.
A workspace is the input playbook or working copy. It is distinct from optional
durable output and from service-owned .agent runtime files. Two entry points
create one:
| Endpoint | Field | What you pass | How the workspace is created |
|---|---|---|---|
POST /api/ag-ui/run |
workspacePath (+ inPlace) |
an existing container path only, no git or URL | In place — binds that exact directory, no copy |
POST /v1/orchestrator/initiate |
folder (+ inPlace) |
a git URL, an archive URL (.zip/.tar.gz/.tgz/.tar over HTTP(S)), a file:// or UNC folder, or a local path |
Provisioned per the source kind below |
initiate source kinds (workspace_manager.classify_source):
Source (folder) |
Handling | Workspace location | Output persisted to host? |
|---|---|---|---|
Local path + inPlace: true |
Runs in place, no copy. Needs ALLOW_INPLACE_WORKSPACE=true and a path under WORKSPACE_ALLOWED_ROOTS |
your folder itself | Yes — written straight back |
Local path + inPlace: false |
Copied into a sandbox | WORKSPACES_ROOT/{executionId}/workspace |
No host target — kept in service storage until operator cleanup |
Git URL (*.git, git@…, github/gitlab/…) |
git clone --depth 1; an optional input.branch adds --branch --single-branch |
WORKSPACES_ROOT/{executionId}/workspace |
No host target |
Archive / shared folder (.zip, .tar.gz, .tgz, .tar, file://, UNC \\host\share) |
Downloaded or copied, then extracted | WORKSPACES_ROOT/{executionId}/workspace |
No host target |
There is no object-store input provider: nothing lists a bucket or container.
A remote source must be a single archive fetched over HTTP(S), so a blob or S3
URL only works when it addresses the .zip/.tar.gz object itself — either
pre-signed, or with credentials.inputAccessToken, which is sent as an
Authorization header (Bearer is added unless the value already carries a
scheme). Object storage appears on the output side only, where the providers
are shared_folder and azure_blob.
With OUTPUT_BINDINGS_ENABLED=true, callers may bind shared_folder or
azure_blob output, and the model gains write_output_local,
edit_output_local, read_output_local and list_output_local. Targeted
edits use exact string replacement and fail on missing or ambiguous text rather
than silently rewriting the file. For Azure,
credentials.outputAccessToken is a SAS supplied fresh with each execute or
resume; it must stay valid for that segment and is never persisted.
New-contract sessions default to workflow, which is observation-only for input
even when output is null. Use working_copy for writable input, or bind
durable output for workflow state. Output paths are logical relative paths, and
.agent/** is service runtime that cannot be written as output. Input, runtime
and output roots stay separate; output is never removed by session lifecycle.
Use the /close endpoints for teardown — /abandon only discards holds. AG-UI
does not enable git or URL input.
To let in-place runs read and write a folder on your machine, bind-mount it at
/workspaces. docker-compose.yml is already wired for this through
WORKSPACES_HOST_DIR:
volumes:
- ${WORKSPACES_HOST_DIR:-./workspaces}:/workspacesAllow in-place binding and restrict it to that mount in src/.env/docker/.env:
ALLOW_INPLACE_WORKSPACE=true
WORKSPACE_ALLOWED_ROOTS=/workspaces
OUTPUT_BINDINGS_ENABLED=falseThen pass the host path when bringing the stack up:
# macOS / Linux
WORKSPACES_HOST_DIR=/Users/you/workflows docker compose up --build -d
# Windows PowerShell
$env:WORKSPACES_HOST_DIR="c:/git/workflows"; docker compose up --build -dCallers now reference a workflow by absolute container path under the mount —
workspacePath: "/workspaces/<folder>" on /api/ag-ui/run, or
folder: "/workspaces/<folder>" with inPlace: true on
/v1/orchestrator/initiate. The path is literal, so every caller sharing the
mount must map the same host folder to the same container path. Durable
output uses a separate shared_folder binding under a root listed in
WORKSPACE_ALLOWED_ROOTS; close never deletes caller-owned input or output, so
the caller owns output retention.
Deployments that send non-null output bindings must opt into
OUTPUT_BINDINGS_ENABLED=true in their own compose or env, and then
WORKSPACE_ALLOWED_ROOTS must cover every workflow and output mount. Local
shared_folder output needs no credentials.
Private git input (planned). Fetching a workflow from a git URL is the service's job: as a deploy-once orchestrator serving many teams it would hold its own service-level git credential rather than accepting tokens per request. Today
initiateclones public git URLs; private-repo auth and a durable output target for cloned input are planned.
ag-ui-demo/ is a protocol sandbox, not production web code. It demonstrates
env-driven bindings on fresh AG-UI runs and thread close with bounded 202
retry; see ag-ui-demo/README.md. Production integration
belongs in the calling application; this service stays a generic orchestrator.
An end-to-end web application built on this orchestrator will be released soon: jb-web-code.
Interactive state is durable, so the service scales horizontally without sticky sessions:
- Frontend tools are per run. The agent loop uses the
frontend_toolspassed with each request, so concurrent sessions and separate applications sharing one deployment never overwrite each other. The process-global cache exists only for thegetToolslisting and legacy bind-only runs. Tool definitions needed for resume are persisted inLLMState.state_payload. - Nothing waits in memory. When a tool awaits a user response — an AG-UI tool
or the built-in
ask_user— the loop persists toLLMStateand returns. Any instance serves the follow-up through/api/ag-ui/run(resume[]),/v1/orchestrator/resume, or/v1/agent/resumeRun. AG-UI locates the hold by interrupt or tool ID; orchestrator and direct-agent resumes load the suppliedstateGuidand claim it atomically. - The only per-pod resource is the SSE socket, which needs no stickiness because resume arrives as a fresh request.
GET /api/ag-ui/events subscribers remain per-process queues. Correctness does
not depend on them; cross-pod dashboard fan-out would need shared pub/sub.
Install the runtime requirements plus pytest and pytest-asyncio, then run the
suite from the repository root:
pytestpytest.ini sets testpaths = tests and asyncio_mode = auto, so async tests
need no marker. Tests marked live require a separately running service and are
excluded by default. The suite needs no database and no network.
scripts/ holds checks that need a running service, so they are not part of the
suite:
python scripts/smoke_service.py # agent endpoints and tool attribution
python scripts/smoke_agui.py # AG-UI bind, stream, interrupt and resume
python scripts/orchestrator_continuity_exercise.py --helpSet PY_MAIN_AGENT_BASE_URL to target something other than
http://localhost:8000, and PY_MAIN_AGENT_SMOKE_MODEL to change the model
(default gpt-4o). Both smoke checks exit non-zero on the first failure. Because
tool names depend on which MCP servers you configured, smoke_service.py takes
them from /getTools unless you set PY_MAIN_AGENT_SMOKE_TOOLS, and
smoke_agui.py exercises only its own UI tools unless you name an MCP tool in
PY_MAIN_AGENT_SMOKE_MCP_TOOL.
orchestrator_continuity_exercise.py is dry-run by default; use
--run --simulate-execute when no LLM is configured, and pass
--host-shared-root (or set WORKSPACES_HOST_DIR) for live Docker runs.
scripts/smoke_agui.py and ag-ui-demo/ demonstrate compatibility paths. Do
not treat them as references for canonical resume[], cancellation,
multi-interrupt, thread-abandon or typed-history behaviour — those contracts are
covered by tests/test_agui_controller_resume.py and
tests/test_agui_batch_resume.py.
The service depends on FastAPI and uvicorn, the mcp client, SQLAlchemy with
asyncpg, Alembic, ag-ui-protocol, and azure-storage-blob for the optional
blob output provider. It talks to LiteLLM over HTTP with httpx rather than
importing the LiteLLM SDK. src/requirements.txt is the authoritative list.
- Serial tool loops. Independent reads are not always batched, so a workflow can need many model round trips. Batch or programmatic tool execution would cut latency and context churn.
- Context thresholds. Token-aware compaction exists, but character thresholds remain the default. A model-window fraction trigger would be safer across providers.
- Offload lifecycle.
.agent/offload/preserves oversized results; fresh AG-UI runs prune unreferenced files when discarding stale awaits, and thread close removes runtime. Durable output is never session-cleaned. - Prompt caching. Static harness sections are not explicitly segmented for provider prompt-cache reuse.
- Diagnostics. There is no
/context-style endpoint exposing the loaded root instructions, catalogs, tools and context budget. - Harness coverage. Only Cursor and Claude Code have dedicated adapters.
Anything else falls back to the generic one, which reads
AGENTS.md(or a README as a last resort), so a repository organised for another tool — GitHub Copilot's.github/copilot-instructions.md, for instance — contributes nothing beyond that. The adapter contract is deliberately small, so these are additions rather than redesigns. - MCP scopes. Named multi-server configuration has no local/project/user precedence or managed policy layer.
- Event fan-out.
/api/ag-ui/eventssubscribers are process-local. - Pending-state visibility. There are no metrics for rows stuck in
awaiting_response, which would surface abandoned interactions.
- docs/ORCHESTRATOR.md — source of truth for the harness: workspace and instruction loading, prompt composition, the tool loop, compaction, resume, lifecycle and close, persistence, and roadmap
- docs/MULTI_SERVER_IMPLEMENTATION.md — multiple MCP servers and universal tool attribution
- docs/DOCUMENTATION_INDEX.md — navigation
- ag-ui-demo/README.md — protocol sandbox caller
- SECURITY.md — threat model, hardening checklist, and how to report a vulnerability privately
- CONTRIBUTING.md — what belongs in the engine, the branch and review flow, and the local test recipe
- CODE_OF_CONDUCT.md — community expectations
changes/— what shipped, per change
First-party code in this repository is licensed under the Apache License, Version 2.0. See LICENSE and NOTICE. Third-party material remains subject to its own license and notices.