Contents · 18 sections↓
- 01How do you manage running AI agents over an API?
- 02How an agent control API works
- 03When to use a control API (and when not to)
- 04What you need
- 05Step 1: Start the runtime with the API
- 06Step 2: Deploy and inspect an agent
- 07Step 3: Change a running agent's config
- 08Step 4: Message the agent and manage its triggers
- 09Step 5: Rotate secrets, read the journal and check health
- 10Step 6: Use the Python client
- 11Step 7: Lock the API down
- 12Which routes does the agent control API have?
- 13The complete example
- 14Running it in production
- 15Common problems and fixes
- 16Limitations
- 17Frequently asked questions
- 18Next steps
An agent that runs for days needs a way to manage it without a redeploy: stop it, raise its budget, change its prompt, rotate a key or ask it what it is doing. An AI agent control API gives you that over HTTP. This guide is for developers who run long-lived agents. It explains how a control API for running agents works, shows every route of the Promptise Foundry Agent Runtime's API with real curl calls and responses, and covers how to lock it down.
How do you manage running AI agents over an API?
Put a small HTTP server in front of the process that runs your agents and map each management action to a route. In Promptise Foundry that server is OrchestrationAPI. You pass it your AgentRuntime and a bearer token, and it serves 37 routes under /api/v1: deploy, start, stop and restart agents; patch instructions, budget, health and mission settings; send messages and ask questions; manage triggers and secrets; read the journal, anomalies, metrics and state. Bind it to 127.0.0.1 and put a TLS proxy in front when you need remote access.
api = OrchestrationAPI(runtime, host="127.0.0.1", port=9100, auth_token="${ORCHESTRATION_API_TOKEN}")
await api.start()Every route except `GET /api/v1/health` needs the token. A non-loopback bind without a token is refused at start-up.
Changes apply to the live process. Instructions take effect on the next run, budget at the next budget check, health and mission right away.
Config patches are validated as a whole. A bad value or an unknown field returns 422 and changes nothing.
A loopback bind refuses browsers. A foreign Host header gets 421, a cross-site Origin gets 403.
Everything you change lives in memory. A new Python process starts again from the config you deployed.
[02]
How an agent control API works
A runtime holds several agent processes. Each process has a lifecycle (running, suspended, stopped), its triggers, and optional subsystems: a budget, a health monitor, a mission, a secret scope, a message inbox, a journal and a key-value context. A control API is a thin layer that turns HTTP requests into method calls on these objects. It doesn't run the agent itself, and it holds no state of its own.
Every request passes the same checks before it reaches a route:
HTTP request
│
▼
┌───────────────────────────────┐ bound to a loopback address only:
│ 1. Host header loopback? │── no ──▶ 421 MISDIRECTED_REQUEST (DNS rebinding)
│ 2. Origin absent or loopback? │── no ──▶ 403 CROSS_ORIGIN_REFUSED (browser page)
└───────────────────────────────┘
│
▼
┌───────────────────────────────┐
│ 3. Bearer token matches? │── no ──▶ 401 UNAUTHORIZED (/api/v1/health is exempt)
│ 4. Body is a JSON object? │── no ──▶ 400 INVALID_JSON
└───────────────────────────────┘
│
▼
route handler ──▶ AgentRuntime / AgentProcess ──▶ JSON response
(lifecycle, config, inbox, secrets, journal, context)When a change takes effect depends on what it touches. A new system prompt is applied when the next run starts, so a run in progress finishes with the old one. Budget limits are read at the next budget check. Health thresholds and mission settings are read on every check. A new trigger is only a config entry until the process restarts, because triggers are built when a process starts. The API tells you which of these applies in its effective_from field.
Two terms matter for security. DNS rebinding is an attack in which a web page makes its own host name resolve to 127.0.0.1, so the browser sends requests to a local service while the Host header still names the attacker's domain. A cross-site request is a fetch or form post from a page you have open, and the browser marks it with an Origin header. Both let a web page reach a server that is bound only to localhost, which is why "it only listens on localhost" is not access control on its own.
[03]
When to use a control API (and when not to)
Situation | Use the control API? |
|---|---|
Agents run for hours or days and operators need to steer them | Yes |
You want a dashboard, CLI or chat bot to pause agents or raise budgets | Yes, through OrchestrationClient or plain HTTP |
Another service needs to send instructions or questions to an agent | Yes, through the message routes |
Agents are short scripts that you run and finish | No, change the code and run it again |
You need config changes to survive a restart of the host process | Not alone; also update the config file or manifest you deploy from |
You need a multi-user admin panel with roles | Not alone; the API has one shared token and no roles |
The API is an operator tool. Anyone with the token can deploy a process with any instructions, read the journal and replace secrets, so treat the token like a production database password.
[04]
What you need
Python 3.10 or later and Promptise Foundry 1.4.3 or later.
An OpenAI API key in OPENAI_API_KEY. The process is built with openai:gpt-5-mini, but none of the steps below starts a model run, so they cost nothing.
curl, and about 20 minutes.
pip install "promptise>=1.4.3"
export OPENAI_API_KEY=sk-...[05]
Step 1: Start the runtime with the API
Start with an empty runtime and attach the API to it. The token is read from the environment: auth_token="${ORCHESTRATION_API_TOKEN}" is resolved when the API is constructed, and a missing variable raises ValueError, so a typo can't start an unprotected server.
"""Run an empty agent runtime with the control API on 127.0.0.1:9100."""
import asyncio
import logging
from promptise.runtime import AgentRuntime, OrchestrationAPI
logging.basicConfig(level=logging.INFO, format="%(name)s: %(message)s")
async def main() -> None:
runtime = AgentRuntime()
api = OrchestrationAPI(
runtime,
host="127.0.0.1",
port=9100,
auth_token="${ORCHESTRATION_API_TOKEN}", # read from the environment
)
await api.start()
try:
await asyncio.Event().wait() # serve until Ctrl+C
finally:
await api.stop()
await runtime.stop_all()
asyncio.run(main())The curl examples share three settings:
# Shared settings for the curl examples.
export ORCHESTRATION_API_TOKEN=demo-token-change-me
API=http://127.0.0.1:9100/api/v1
AUTH="Authorization: Bearer $ORCHESTRATION_API_TOKEN"Run python serve.py in one terminal and the checks in another:
source env.sh
curl -s -w " HTTP %{http_code}\n" $API/health
curl -s -w " HTTP %{http_code}\n" $API/runtime/status
curl -s -w " HTTP %{http_code}\n" -H "$AUTH" $API/runtime/status{"status": "healthy", "process_count": 0} HTTP 200
{"error": {"code": "UNAUTHORIZED", "message": "Invalid or missing auth token"}} HTTP 401
{"process_count": 0, "processes": {}} HTTP 200/api/v1/health answers without a token, so a load balancer or Kubernetes probe can call it. Every other route, including unknown paths, returns 401 without the token. OrchestrationAPI takes four arguments: runtime, host (default 127.0.0.1), port (default 9100, or 0 to let the OS pick one, which api.port reports after start()) and auth_token (default None).
[06]
Step 2: Deploy and inspect an agent
POST /api/v1/processes creates a process from JSON. The config object takes the same fields as ProcessConfig. This one enables the inbox, a budget, health monitoring, a mission, one secret, a journal and a context with one writable key:
{
"name": "monitor",
"start": true,
"config": {
"model": "openai:gpt-5-mini",
"instructions": "You watch the nightly data pipelines and report failures.",
"inbox": {"enabled": true},
"budget": {"enabled": true, "max_runs_per_day": 200, "max_cost_per_day": 5.0},
"health": {"enabled": true},
"mission": {
"enabled": true,
"objective": "Keep the nightly pipelines green",
"success_criteria": "No failed pipeline for 7 days"
},
"secrets": {"enabled": true, "secrets": {"status_api_key": "${STATUS_API_KEY}"}},
"journal": {"level": "checkpoint", "path": ".promptise/journal"},
"context": {
"initial_state": {"paused_pipelines": []},
"writable_keys": ["paused_pipelines"]
}
}
}source env.sh
curl -s -X POST $API/processes -H "$AUTH" -H "Content-Type: application/json" -d @monitor.json; echo
curl -s -H "$AUTH" $API/processes; echo
curl -s -H "$AUTH" $API/processes/monitor | python3 -m json.tool{"name": "monitor", "process_id": "9a531224-1330-416f-8da1-ee01b479abdc", "state": "running", "created_at": 1792431991.890294}
{"processes": [{"name": "monitor", "state": "running", "process_id": "9a531224-1330-416f-8da1-ee01b479abdc"}], "total": 1}
{
"name": "monitor",
…
"state": "running",
…
"invocation_count": 0,
…
"journal_enabled": true,
"trigger_count": 0,
…
"budget": {
…
"runs_day": 200,
"cost_day": 5.0
},
"health_anomalies": 0,
"mission_state": "active",
"active_secrets": 1
}The deploy returns 201 with the process ID. "start": true starts the process right away; without it the process stays in created until you call /start. A process name must start with a letter and use only letters, digits, hyphens and underscores, up to 64 characters (422 INVALID_NAME otherwise). A config that fails validation returns 422 INVALID_CONFIG with the field errors, and a name that already exists returns 409 DEPLOY_FAILED.
GET /api/v1/processes/{name} returns the process status: state, run and failure counters, queue size, dead letters and, for each enabled subsystem, a summary such as the remaining budget or the mission state. GET /api/v1/runtime/status returns the same for every process. POST /api/v1/runtime/start-all and stop-all act on all of them.
[07]
Step 3: Change a running agent's config
Four PATCH routes change a running process without a restart:
source env.sh
curl -s -X PATCH $API/processes/monitor/instructions -H "$AUTH" \
-d '{"instructions": "You watch the nightly pipelines. Report failures in one line."}'; echo
curl -s -X PATCH $API/processes/monitor/budget -H "$AUTH" -d '{"max_cost_per_day": 10.0}'; echo
curl -s -X PATCH $API/processes/monitor/budget -H "$AUTH" -d '{"max_cost_per_day": "10"}'; echo
curl -s -X PATCH $API/processes/monitor/budget -H "$AUTH" -d '{"max_cost_per_week": 50}'; echo
curl -s -X PATCH $API/processes/monitor/health -H "$AUTH" -d '{"stuck_threshold": 4, "on_anomaly": "pause"}'; echo
curl -s -X PATCH $API/processes/monitor/mission -H "$AUTH" -d '{"success_criteria": "No failed pipeline for 14 days"}'; echo{"name": "monitor", "instructions_updated": true, "effective_from": "next_invocation"}
{"name": "monitor", "budget_updated": true, "effective_from": "next_budget_check"}
{"error": {"code": "INVALID_CONFIG", "message": "Config validation failed: max_cost_per_day: Input should be a valid number"}}
{"error": {"code": "UNKNOWN_FIELD", "message": "Unknown field(s): max_cost_per_week. Allowed: enabled, max_tool_calls_per_run, max_llm_turns_per_run, max_cost_per_run, max_irreversible_per_run, max_tool_calls_per_day, max_runs_per_day, max_cost_per_day, tool_costs, on_exceeded, escalation, daily_reset_hour_utc, inject_remaining"}}
{"name": "monitor", "health_updated": true, "effective_from": "immediate"}
{"name": "monitor", "mission_updated": true, "effective_from": "immediate"}`/instructions` takes {"instructions": "..."}. The next run rebuilds the agent with the new prompt and keeps its MCP tools, conversation history and state.
`/budget`, `/health` and `/mission` take the fields to change, named as in BudgetConfig, HealthConfig and MissionConfig. The patched section is validated with strict JSON types, so "10" is not a number. An unknown field returns 422 UNKNOWN_FIELD and lists the allowed ones. Nothing changes unless the whole patch is valid. null clears an optional limit.
These routes tune a subsystem that is already on. They can't switch one on: budget, health and mission objects are created when the process is constructed, so PATCH /health with {"enabled": true} on a process deployed without health monitoring returns 200 and still monitors nothing. Deploy with the subsystem enabled, or remove the process and deploy it again. The same goes for the health monitor's window sizes: loop_window and error_window size its history buffers when the process is created, so a larger value patched later can't see further back than the original. Budget limits and what happens when they are reached are covered in AI agent cost control.
[08]
Step 4: Message the agent and manage its triggers
The message routes reach the agent's inbox. A message waits there until the agent's next run, which includes it in the prompt:
source env.sh
curl -s -X POST $API/processes/monitor/messages -H "$AUTH" \
-d '{"content": "Ignore the staging pipeline until 18:00.", "message_type": "directive", "priority": "high", "sender_id": "ops-anna", "ttl": 3600}'; echo
curl -s -H "$AUTH" $API/processes/monitor/inbox; echo
curl -s -X POST $API/processes/monitor/ask -H "$AUTH" \
-d '{"content": "Which pipelines failed last night?", "sender_id": "ops-anna", "timeout": 3}'; echo
curl -s -X DELETE -o /dev/null -w "%{http_code}\n" -H "$AUTH" $API/processes/monitor/inbox{"message_id": "msg_761d73f9847ee5959048366e", "status": "queued", "expires_at": 1792435591.971534}
{"pending_messages": 1, "pending_questions": 0, "oldest_message_age_seconds": 0.0, "messages": [{"message_id": "msg_761d73f9847ee5959048366e", "type": "directive", "priority": "high", "age_seconds": 0.0, "sender_id": "ops-anna"}]}
{"question_id": "msg_d3b22149266bb2ed7b7b5877", "status": "timeout", "response": null}
204POST /messages queues a message (201) with a message_type of directive, context, question or correction, a priority, a sender_id and a ttl in seconds. POST /ask queues a question and holds the connection until the agent answers or timeout seconds pass. Here no run happened in three seconds, so the answer is "status": "timeout". A question is answered only when a trigger starts a run. GET /inbox lists pending messages and DELETE /inbox clears them (204). Both message routes return 400 INBOX_DISABLED when the process has no inbox and 429 RATE_LIMITED when a sender goes over its hourly limit. Message a running AI agent covers the inbox in depth.
Triggers are added and removed through the config:
source env.sh
running() { curl -s -H "$AUTH" $API/processes/monitor | python3 -c "import json,sys; print('running triggers:', json.load(sys.stdin)['trigger_count'])"; }
curl -s -X POST $API/processes/monitor/triggers -H "$AUTH" \
-d '{"type": "cron", "cron_expression": "0 6 * * *"}'; echo
curl -s -H "$AUTH" $API/processes/monitor/triggers | python3 -c "
import json, sys
for t in json.load(sys.stdin)['triggers']:
print(t['id'], t['type'], t['source'], t['config']['cron_expression'])"
running
curl -s -X POST -H "$AUTH" $API/processes/monitor/restart; echo
running
curl -s -X DELETE -H "$AUTH" $API/processes/monitor/triggers/static_0; echo
running{"name": "monitor", "trigger_added": true, "trigger_type": "cron"}
static_0 cron config 0 6 * * *
running triggers: 0
{"name": "monitor", "state": "running"}
running triggers: 1
{"name": "monitor", "trigger_removed": "static_0"}
running triggers: 1The new cron trigger appears in the list at once as static_0, but nothing runs it until the restart. Removing it works the same way in reverse: the config entry is gone, and the running cron trigger keeps firing until the next restart. IDs are list positions, so after you remove static_0 the next trigger becomes static_0. List before you delete. Trigger types and options are covered in event-driven AI agents.
[09]
Step 5: Rotate secrets, read the journal and check health
Secrets, the journal, the mission, anomalies, suspension, metrics and context each have their own routes:
source env.sh
curl -s -H "$AUTH" $API/processes/monitor/secrets; echo
curl -s -X PATCH $API/processes/monitor/secrets/status_api_key -H "$AUTH" -d '{"value": "sk-status-rotated"}'; echo
curl -s -X PATCH $API/processes/monitor/secrets/no_such_key -H "$AUTH" -d '{"value": "x"}'; echo
curl -s -X DELETE -H "$AUTH" $API/processes/monitor/secrets; echo
curl -s -H "$AUTH" $API/processes/monitor/secrets; echo{"name": "monitor", "secrets": [{"name": "status_api_key", "active": true}], "enabled": true}
{"name": "monitor", "secret_rotated": "status_api_key"}
{"error": {"code": "PROCESS_NOT_FOUND", "message": "Process 'monitor' not found"}}
{"name": "monitor", "secrets_revoked": true}
{"name": "monitor", "secrets": [], "enabled": true}GET /secrets lists names and whether each is active, never values. PATCH /secrets/{secret} replaces a value in memory at once. DELETE /secrets revokes all of them. Two details matter. A misspelled secret name returns 404 PROCESS_NOT_FOUND in 1.4.3, which names the process rather than the secret, so check the name before you chase a missing process. And a restart resolves secrets from the deployed config again, so a rotated value reverts to the original. Update the source the config reads from as well. See AI agent secrets management.
source env.sh
curl -s -H "$AUTH" "$API/processes/monitor/journal?limit=3&type=state_transition" | python3 -m json.tool{
"name": "monitor",
"entries": [
…,
{
"entry_type": "state_transition",
"timestamp": "2026-10-19 17:46:35.159481+00:00",
"data": {
"from_state": "starting",
"to_state": "running",
"reason": "startup complete",
"process_id": "9a531224-1330-416f-8da1-ee01b479abdc"
}
}
],
"count": 3
}GET /journal returns entries oldest first, filtered by type and capped by limit (default 50, maximum 500). Entries are filed under the process name, so they survive restarts. A process without a journal returns "journal_enabled": false.
source env.sh
curl -s -H "$AUTH" $API/processes/monitor/mission; echo
curl -s -X POST -H "$AUTH" $API/processes/monitor/mission/pause; echo
curl -s -X POST -H "$AUTH" $API/processes/monitor/mission/resume; echo
curl -s -H "$AUTH" $API/processes/monitor/health/anomalies; echo
curl -s -X POST -H "$AUTH" $API/processes/monitor/suspend; echo
curl -s -H "$AUTH" $API/processes/monitor | python3 -c "import json,sys; print('state:', json.load(sys.stdin)['state'])"
curl -s -X POST -H "$AUTH" $API/processes/monitor/resume; echo{"name": "monitor", "mission_enabled": true, "mission": {"state": "active", "objective": "Keep the nightly pipelines green", "invocation_count": 0, "evaluations": []}}
{"name": "monitor", "mission_paused": true}
{"name": "monitor", "mission_resumed": true}
{"name": "monitor", "anomalies": [], "count": 0}
{"name": "monitor", "suspended": true}
state: suspended
{"name": "monitor", "resumed": true}The mission routes return the objective, state and judge evaluations, and pause, resume or fail the mission. /health/anomalies lists what the health monitor detected, and DELETE clears the list after you fix the cause. /suspend moves the process to suspended: triggers keep firing, but their events wait in the queue until /resume.
source env.sh
curl -s -H "$AUTH" $API/processes/monitor/context; echo
curl -s -X PATCH $API/processes/monitor/context -H "$AUTH" -d '{"state": {"paused_pipelines": ["staging"]}}'; echo
curl -s -X PATCH $API/processes/monitor/context -H "$AUTH" -d '{"state": {"owner": "anna"}}'; echo
curl -s -H "$AUTH" $API/processes/monitor/metrics | python3 -c "import json,sys; d=json.load(sys.stdin); print({k: d[k] for k in ('state','invocation_count','budget','health_anomalies','mission_state')})"{"state": {"paused_pipelines": []}, "writable_keys": ["paused_pipelines"]}
{"name": "monitor", "updated_keys": ["paused_pipelines"]}
{"error": {"code": "WRITE_DENIED", "message": "Cannot write key 'owner': \"Key 'owner' is not writable. Allowed: ['paused_pipelines']\""}}
{'state': 'running', 'invocation_count': 0, 'budget': {'tool_calls_run': None, 'llm_turns_run': None, 'cost_run': None, 'irreversible_run': None, 'tool_calls_day': None, 'runs_day': 200, 'cost_day': 10.0}, 'health_anomalies': 0, 'mission_state': 'active'}GET /metrics returns the same status object as GET /processes/{name}. GET /context returns the agent's key-value state and writable keys, and PATCH /context writes keys with {"state": {...}}. A key outside writable_keys returns 403 WRITE_DENIED. AI agent state between runs explains that state.
[10]
Step 6: Use the Python client
OrchestrationClient wraps every route in an async method. It uses httpx, takes the token as token=, and raises httpx.HTTPStatusError for any 4xx or 5xx response:
"""Drive the same API from Python with OrchestrationClient."""
import asyncio
import os
import httpx
from promptise.runtime import OrchestrationClient
TOKEN = os.environ["ORCHESTRATION_API_TOKEN"]
async def main() -> None:
async with OrchestrationClient("http://127.0.0.1:9100", token=TOKEN, timeout=30) as client:
print(await client.health())
created = await client.deploy(
"reporter",
{
"model": "openai:gpt-5-mini",
"instructions": "Summarize the pipeline report you are given.",
"inbox": {"enabled": True},
"budget": {"enabled": True, "max_runs_per_day": 50},
},
start=True,
)
print("deployed:", created["name"], created["state"])
await client.update_budget("reporter", max_runs_per_day=100)
sent = await client.send_message(
"reporter", "The finance pipeline moved to 03:00 UTC.", sender_id="ops-anna"
)
print("message:", sent["status"])
print("inbox:", (await client.get_inbox("reporter"))["pending_messages"], "pending")
answer = await client.ask("reporter", "Anything new?", timeout=2)
print("ask with no run in the window returns:", repr(answer))
try:
await client.get_process("nightly")
except httpx.HTTPStatusError as exc:
print("error:", exc.response.status_code, exc.response.json()["error"]["code"])
print("stopped:", (await client.stop_process("reporter"))["state"])
asyncio.run(main()){'status': 'healthy', 'process_count': 0}
deployed: reporter running
message: queued
inbox: 1 pending
ask with no run in the window returns: None
error: 404 PROCESS_NOT_FOUND
stopped: stoppedclient.ask() returns the answer text, or None when the agent didn't answer within timeout. It sets its own HTTP timeout to timeout + 10 seconds, so a long ask doesn't hit the client's default of 30. In 1.4.3, remove_process() and clear_inbox() raise JSONDecodeError after the server has done the work, because the routes answer 204 with no body. Call those two routes with plain httpx or curl for now.
[11]
Step 7: Lock the API down
Run the same server and send the requests a browser would send:
source env.sh
# DNS rebinding: the browser sends the attacker's host name in Host.
curl -s -w " HTTP %{http_code}\n" -H "$AUTH" -H "Host: rebind.attacker.example:9100" $API/runtime/status
# A cross-site fetch from a page open in the browser.
curl -s -w " HTTP %{http_code}\n" -H "$AUTH" -H "Origin: https://attacker.example" $API/runtime/status
# A local web UI on another localhost port is allowed.
curl -s -w " HTTP %{http_code}\n" -H "$AUTH" -H "Origin: http://localhost:3000" $API/health
# A wrong token.
curl -s -w " HTTP %{http_code}\n" -H "Authorization: Bearer wrong" $API/runtime/status
# A body that is not a JSON object.
curl -s -w " HTTP %{http_code}\n" -X POST $API/processes/monitor/messages -H "$AUTH" -d '["hello"]'{"error": {"code": "MISDIRECTED_REQUEST", "message": "Misdirected request"}} HTTP 421
{"error": {"code": "CROSS_ORIGIN_REFUSED", "message": "Cross-origin request refused"}} HTTP 403
{"status": "healthy", "process_count": 1} HTTP 200
{"error": {"code": "UNAUTHORIZED", "message": "Invalid or missing auth token"}} HTTP 401
{"error": {"code": "INVALID_JSON", "message": "Request body must be a JSON object"}} HTTP 400On a loopback bind, the API refuses a Host header that isn't localhost, 127.x.x.x or [::1] with 421, and an Origin that isn't a loopback origin with 403. A web UI served from http://localhost:<port> is allowed. curl, scripts and OrchestrationClient send a loopback Host and no Origin, so they work. The token check uses a timing-safe comparison. The Host and Origin checks run only on loopback binds. On any other address, the token is the only check.
Without a token, a loopback bind accepts every local caller. That includes other users on a shared machine and any process in the same container, so set a token even on localhost.
[12]
Which routes does the agent control API have?
Method | Path under /api/v1 | Does |
|---|---|---|
GET | /health | Liveness probe, no token |
GET | /runtime/status | Status of every process |
POST | /runtime/start-all, /runtime/stop-all | Start or stop every process |
GET, POST | /processes | List processes; deploy one (201) |
GET, DELETE | | Status; stop and remove (204) |
POST | /processes/{name}/start, /stop, /restart | Lifecycle |
POST | /processes/{name}/suspend, /resume | Hold or release queued trigger events |
PATCH | | New system prompt from the next run |
PATCH | /processes/{name}/budget, /health, /mission | Validated config patch |
POST | | Queue an inbox message (201) |
POST | | Ask and wait for the answer |
GET, DELETE | | Inbox status; clear it (204) |
GET, POST | | List; add a trigger to the config |
DELETE | | Remove a trigger by ID |
GET, DELETE | | Names and status; revoke all |
PATCH | | Rotate one value |
GET | | Journal entries |
GET | | Mission state and evaluations |
POST | /processes/{name}/mission/fail, /pause, /resume | Mission control |
GET, DELETE | | Anomaly history; clear it |
GET | | Same status object as GET /processes/{name} |
GET, PATCH | | Read state; write writable keys |
Every error has the shape {"error": {"code": "...", "message": "..."}}. Common codes are UNAUTHORIZED (401), PROCESS_NOT_FOUND and TRIGGER_NOT_FOUND (404), MISSING_FIELD, INVALID_CONFIG and UNKNOWN_FIELD (422), INBOX_DISABLED, SECRETS_DISABLED and MISSION_DISABLED (400), and RATE_LIMITED (429).
[13]
The complete example
control-api-demo/
├── serve.py # runtime + OrchestrationAPI on 127.0.0.1:9100 (step 1)
├── env.sh # token and base URL for the curl scripts
├── monitor.json # the process to deploy (step 2)
├── 01_health_auth.sh … 10_guard.sh # one script per step
└── client.py # the same calls through OrchestrationClient (step 6)pip install "promptise>=1.4.3"
export OPENAI_API_KEY=sk-... ORCHESTRATION_API_TOKEN=demo-token-change-me STATUS_API_KEY=sk-status
python serve.py &
for s in 0*.sh 10_guard.sh; do bash "$s"; done # steps 1 to 5 and 7
python client.py # step 6To stop and remove the process at the end:
source env.sh
curl -s -X POST -H "$AUTH" $API/processes/monitor/stop; echo
curl -s -X DELETE -o /dev/null -w "%{http_code}\n" -H "$AUTH" $API/processes/monitor
curl -s -H "$AUTH" $API/processes/monitor; echo{"name": "monitor", "state": "stopped"}
204
{"error": {"code": "PROCESS_NOT_FOUND", "message": "Process 'monitor' not found"}}[14]
Running it in production
Expose it behind a reverse proxy with TLS
OrchestrationAPI speaks plain HTTP, so the token crosses the network in clear text unless something in front adds TLS. There are two sound layouts. In the first, the API stays on 127.0.0.1 and a proxy on the same host terminates TLS and forwards to it. The proxy must send a loopback Host, which nginx does by default for proxy_pass http://127.0.0.1:9100. If your proxy config forwards the public host name (proxy_set_header Host $host;), every request gets 421. In the second layout, the API binds a private address, such as 0.0.0.0 inside a container, with a token. The loopback checks are then off, so a firewall or network policy must keep the port private, and only the proxy or ingress is public.
server {
listen 443 ssl;
server_name agents.internal.example.com;
ssl_certificate /etc/ssl/agents.crt;
ssl_certificate_key /etc/ssl/agents.key;
allow 10.0.0.0/8; # operators' network only
deny all;
location /api/v1/ {
proxy_pass http://127.0.0.1:9100; # keeps Host: 127.0.0.1:9100
proxy_read_timeout 300s; # long enough for /ask
}
}Set the proxy's read timeout above your longest /ask timeout, or the proxy cuts the long poll.
Keep the token out of code and rotate it
Read the token from the environment or a secret manager with "${ORCHESTRATION_API_TOKEN}", and give each environment its own. The API holds one token, so rotation means restarting it with a new value and updating every client. For per-user access, put an authenticating proxy in front and keep the API token inside your network.
Remember that changes live in memory
Patches, rotated secrets, added triggers and context writes change the running objects only. They survive /restart, which stops and starts the same process object, with two exceptions: secrets are resolved from config again, and the conversation history is cleared. They don't survive a restart of the Python process. Write every change you want to keep into the config or manifest you deploy from as well. Context state can be recovered from the journal, as durable AI agents shows.
Keep an audit trail
aiohttp logs each request to the aiohttp.access logger with method, path and status, but not who made the change or what the body held. Record operator actions in the system that calls the API, such as your CLI, bot or dashboard, and enable the process journal so state transitions are recorded. Pair it with AI agent notifications to alert a channel when a process fails or a budget runs out.
Probe liveness, not readiness
GET /api/v1/health reports that the API answers and how many processes exist. It says nothing about whether the agents are healthy. For alerts, poll /runtime/status with the token and check each process's state, consecutive_failures and health_anomalies.
[15]
Common problems and fixes
Symptom | Cause | Fix |
|---|---|---|
| A public or container bind without a token | Pass auth_token; the API refuses to run open on a network |
Every request through the proxy returns 421 MISDIRECTED_REQUEST | The proxy forwards the public Host to a loopback-bound API | Forward with the upstream host (nginx default), or bind a private address with a token |
A browser dashboard gets 403 CROSS_ORIGIN_REFUSED | The page's Origin is not a loopback origin | Serve the UI from localhost, or call the API from your backend |
/ask always returns "status": "timeout" | No trigger started a run within timeout | Ask when a run is due, raise timeout, or fire a trigger |
A trigger added with POST /triggers never fires | Triggers are built when a process starts | Call /restart after adding or removing triggers |
PATCH /health {"enabled": true} returns 200 but nothing changes | Subsystems are created at deploy time | Deploy the process with the subsystem enabled |
Rotating a secret returns 404 PROCESS_NOT_FOUND | The secret name doesn't exist (1.4.3 reports it as the process) | Check the name with GET /secrets |
client.remove_process() raises JSONDecodeError | The route answers 204 with no body | Use curl or httpx for DELETE /processes/{name} and DELETE /inbox |
[16]
Limitations
One token, no roles. Every caller can do everything. Put an authenticating proxy in front for per-user access and audit.
No TLS of its own. Terminate TLS in a proxy or ingress.
In-memory changes. Nothing the API changes is written back to your config files.
Trigger changes need a restart, and trigger IDs are list positions that shift.
No streaming. /ask is a long poll that returns one answer. For live output, use the journal or the event notifications.
One runtime per API. To manage runtimes on several hosts, run one API per host and put your own aggregation in front.
[17]
Frequently asked questions
How do I stop a running AI agent without killing the server?
Call POST /api/v1/processes/{name}/stop. The process finishes shutting down, stops its triggers and moves to stopped, while the runtime, the API and other agents keep running. POST /start brings it back with the same context state; the conversation history is cleared when a process stops. To hold work without stopping, use POST /suspend: triggers keep firing, and their events wait in the queue until POST /resume.
Can I change an agent's system prompt while it runs?
Yes. PATCH /api/v1/processes/{name}/instructions with {"instructions": "..."} replaces the prompt. A run already in progress finishes with the old prompt, and the next run rebuilds the agent with the new one. The agent keeps its MCP tools, conversation history and state, so no restart is needed. The change lives in memory, so update your config file too.
Is it safe to bind the agent control API to localhost without a token?
Only on a machine where you trust every local process and user. The loopback checks stop web pages in your browser, but any program on the host, or in the same container, can call the API without a token. Set auth_token even on localhost on shared machines, CI runners and servers.
Why does my request get 421 Misdirected Request?
The API is bound to a loopback address and the request's Host header names something else. That is what a DNS-rebinding attack looks like, so the API refuses it. A reverse proxy that forwards the public host name causes the same response. Let the proxy send the upstream host, or bind the API to a private address with a token.
How do I send a message to a running agent over HTTP?
POST /api/v1/processes/{name}/messages with content, a message_type (directive, context, question or correction), and optionally priority, sender_id and ttl. The process needs inbox.enabled. The message waits in the inbox and appears in the prompt of the agent's next run. Use POST /ask when you need an answer back.
Does the control API work with Kubernetes probes?
Yes for liveness. GET /api/v1/health needs no token and returns {"status": "healthy", "process_count": N}. For readiness or alerting, call /runtime/status with the token and check each process's state and failure counters, because the health route doesn't look at the agents.
[18]
Next steps
Understand what the runtime governs: the autonomous AI agent runtime.
Run your first long-lived agent: deploy AI agents in production.
Send instructions and questions to an agent: message a running AI agent.
Put the runtime in a container: deploy an AI agent with Docker.
Pause and resume agents safely: AI agent lifecycle.
Reference: Orchestration API in the Promptise docs.