PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Runtime, triggers and deployment

AI Agent Control API: Manage Running Agents Over HTTP

Manage running AI agents over HTTP: deploy, stop and restart them, patch budgets and prompts, send messages, and secure the API. Real curl calls.

Level
Intermediate
Reading time
20 min
Published
Oct 19, 2026
Last updated
Oct 19, 2026
Tested with
Promptise 1.4.3
By
Promptise Team
  • Agent Runtime
  • Deployment
  • Authentication and Access Control
  • AI Governance and Compliance
  • Observability
Contents · 18 sections↓
  1. 01How do you manage running AI agents over an API?
  2. 02How an agent control API works
  3. 03When to use a control API (and when not to)
  4. 04What you need
  5. 05Step 1: Start the runtime with the API
  6. 06Step 2: Deploy and inspect an agent
  7. 07Step 3: Change a running agent's config
  8. 08Step 4: Message the agent and manage its triggers
  9. 09Step 5: Rotate secrets, read the journal and check health
  10. 10Step 6: Use the Python client
  11. 11Step 7: Lock the API down
  12. 12Which routes does the agent control API have?
  13. 13The complete example
  14. 14Running it in production
  15. 15Common problems and fixes
  16. 16Limitations
  17. 17Frequently asked questions
  18. 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.

[01]

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.

Python
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:

Text
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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.

>_Terminal
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.

Pythonserve.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
"""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:

>_Terminalenv.sh
# 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:

>_Terminal01_health_auth.sh
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
Output
{"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:

JSONmonitor.json
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
  "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"]
    }
  }
}
>_Terminal02_deploy.sh
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
Output
{"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:

>_Terminal03_patch.sh
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
Output
{"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:

>_Terminal04_messages.sh
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
Output
{"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}
204

POST /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:

>_Terminal05_triggers.sh
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
Output
{"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: 1

The 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:

>_Terminal06_secrets.sh
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
Output
{"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.

>_Terminal07_journal.sh
source env.sh
curl -s -H "$AUTH" "$API/processes/monitor/journal?limit=3&type=state_transition" | python3 -m json.tool
Output
{
    "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.

>_Terminal08_mission_health_suspend.sh
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
Output
{"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.

>_Terminal09_metrics_context.sh
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')})"
Output
{"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:

Pythonclient.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
"""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())
Output
{'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: stopped

client.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:

>_Terminal10_guard.sh
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"]'
Output
{"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 400

On 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

Text
/processes/{name}

Status; stop and remove (204)

POST

/processes/{name}/start, /stop, /restart

Lifecycle

POST

/processes/{name}/suspend, /resume

Hold or release queued trigger events

PATCH

Text
/processes/{name}/instructions

New system prompt from the next run

PATCH

/processes/{name}/budget, /health, /mission

Validated config patch

POST

Text
/processes/{name}/messages

Queue an inbox message (201)

POST

Text
/processes/{name}/ask

Ask and wait for the answer

GET, DELETE

Text
/processes/{name}/inbox

Inbox status; clear it (204)

GET, POST

Text
/processes/{name}/triggers

List; add a trigger to the config

DELETE

Text
/processes/{name}/triggers/{id}

Remove a trigger by ID

GET, DELETE

Text
/processes/{name}/secrets

Names and status; revoke all

PATCH

Text
/processes/{name}/secrets/{secret}

Rotate one value

GET

Text
/processes/{name}/journal?limit=&type=

Journal entries

GET

Text
/processes/{name}/mission

Mission state and evaluations

POST

/processes/{name}/mission/fail, /pause, /resume

Mission control

GET, DELETE

Text
/processes/{name}/health/anomalies

Anomaly history; clear it

GET

Text
/processes/{name}/metrics

Same status object as GET /processes/{name}

GET, PATCH

Text
/processes/{name}/context

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

Text
1
2
3
4
5
6
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)
>_Terminal
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 6

To stop and remove the process at the end:

>_Terminal11_stop_remove.sh
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
Output
{"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.

nginx
1
2
3
4
5
6
7
8
9
10
11
12
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

ValueError: auth_token is required when binding to non-localhost address

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.

Contents

  1. 01How do you manage running AI agents over an API?
  2. 02How an agent control API works
  3. 03When to use a control API (and when not to)
  4. 04What you need
  5. 05Step 1: Start the runtime with the API
  6. 06Step 2: Deploy and inspect an agent
  7. 07Step 3: Change a running agent's config
  8. 08Step 4: Message the agent and manage its triggers
  9. 09Step 5: Rotate secrets, read the journal and check health
  10. 10Step 6: Use the Python client
  11. 11Step 7: Lock the API down
  12. 12Which routes does the agent control API have?
  13. 13The complete example
  14. 14Running it in production
  15. 15Common problems and fixes
  16. 16Limitations
  17. 17Frequently asked questions
  18. 18Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Runtime, triggers and deployment

Related guides

All guides in this topic →
  • // 01Intermediate · 18 minAI Agent Health Monitoring: Catch Silent FailuresMonitor AI agent behavior in production: catch empty replies, rising error rates, stuck calls and loops, then log, pause or escalate. Real output.
  • // 02Intermediate · 18 minGoal-Driven AI Agents: Missions With an LLM JudgeGive an AI agent a goal and let an LLM judge decide when it is done: success criteria, confidence thresholds, limits and costs. Runnable code, real output.
  • // 03Intermediate · 17 minAI Agent Secrets Management: Scoped, Expiring CredentialsManage AI agent secrets safely: scope credentials per agent, expire and rotate them, keep them out of prompts and logs, and audit each access. Real output.
  • // 04Intermediate · 19 minMCP Long-Running Tasks: Background Jobs in PythonRun slow MCP tools as background jobs in Python: submit, poll progress, retry failures and cancel, with a real agent, real runs and honest limits.
  • // 05Beginner · 15 minHow to Run, Debug and Deploy an AI Agent in PythonRun, debug and deploy a Python AI agent project with one command: a package.json-style manifest, live traces, a terminal UI, JSON logs and an API.
  • // 06Intermediate · 15 minDeploy AI Agents in Production: Long-Running Python AgentsRun an AI agent as a long-lived Python process: cron and webhook triggers, state between runs, a journal, budgets that pause it, and honest limits.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths