Event Dispatch#
How to turn external events (webhooks, cron ticks) into headless Osprey agent runs.
What You’ll Learn
What the event dispatcher and dispatch worker do
How to bring the pipeline up and fire your first trigger
How to author your own triggers in
triggers.ymlHow the two bearer tokens guard inbound and internal traffic
Prerequisites: A project built from the control-assistant preset
(or any profile with a dispatch: block). Docker/Podman only for the
container path.
Overview#
Event dispatch lets an external event start an agent run with no human at a keyboard. It is built from two services:
Event dispatcher (
python -m osprey.dispatch, port8020) — accepts authenticated webhookPOSTs (and cron ticks), matches them to a trigger, applies the trigger’s tool allowlist and error policy, and forwards the run to a worker. It also serves the monitoring dashboard.Dispatch worker (
python -m osprey.mcp_server.dispatch_worker, port9190) — runs the headless agent session and streams progress back.
flowchart LR
E[External event] -->|POST /webhook/name| D[Event dispatcher :8020]
D -->|allowlist + policy| W[Dispatch worker :9190]
W -->|headless agent run| R[Result + SSE stream]
D --- Dash[Dashboard /dashboard]
The control-assistant preset ships this enabled, wired to four
control-system-free tutorial triggers so you can exercise the full pipeline
with a single curl. osprey build writes triggers.yml, both service
compose templates, and the services.{event_dispatcher,dispatch_worker}
config into your project, and appends both to deployed_services.
Bring It Up#
Both services are registered in deployed_services, so they come up with the
rest of the stack. One command, secure by default — osprey deploy up
auto-generates both bearer tokens into .env when they are unset:
osprey deploy up # add --dev to bake in a local osprey checkout
The first build is slow: the shared dispatcher/worker image installs Node and the agent CLI the worker runs on.
Image build & overrides
The dispatcher and worker share one image, built locally from
services/event_dispatcher/Dockerfile on first osprey deploy up. Pass
--dev to install your local osprey checkout (incl. unreleased code) via a
wheel; otherwise the image installs osprey-framework from PyPI. To use a
prebuilt/published image instead of building, set the override env vars:
OSPREY_DISPATCH_IMAGE=my-registry/osprey-dispatch:dev \
OSPREY_WORKER_IMAGE=my-registry/osprey-dispatch:dev \
osprey deploy up
Inside the compose network the worker is reachable as
dispatch-worker-1:9190 — the default dispatch_target in
triggers.yml. See Container Deployment for the deploy mechanics.
Run without containers (dev)
Both services are plain Python entrypoints, so you can run them straight from
your venv — handy for development. First repoint the worker URL in
triggers.yml (the Docker hostname does not resolve on the host):
dispatcher:
dispatch_target: http://localhost:9190
Generate the two bearer tokens once (the containerized path does this for you; here you set them by hand) and export them so both shells share them:
export EVENT_DISPATCHER_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
export DISPATCH_WORKER_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
Start the worker (it reads config.yml to inject the same provider auth
the web server uses):
OSPREY_PROJECT_DIR="$PWD" \
DISPATCH_WORKER_TOKEN="$DISPATCH_WORKER_TOKEN" DISPATCH_WORKER_PORT=9190 \
uv run python -m osprey.mcp_server.dispatch_worker
Start the dispatcher in a second shell (re-export the same two tokens there first):
TRIGGERS_YML="$PWD/triggers.yml" \
EVENT_DISPATCHER_TOKEN="$EVENT_DISPATCHER_TOKEN" DISPATCH_WORKER_TOKEN="$DISPATCH_WORKER_TOKEN" \
FASTMCP_TRANSPORT=http FASTMCP_HOST=127.0.0.1 FASTMCP_PORT=8020 \
uv run python -m osprey.dispatch
Fire a Trigger#
The bundled tutorial_triggers.yml defines four demos, each isolating one
concept:
hello-dispatch— anatomy of a trigger and a first successful round-trip (zero tools, empty payload).triage-event— the webhook JSON body becomes the agent’s context; it reasons about the event with no tools.save-report— tool use across a short multi-turn loop, persisting a status report as an artifact in the worker workspace.denied-tool-demo— requestsWebFetchto prove the worker’s server-side denylist rejects it regardless of the trigger’s allowlist.
First read the generated token back from .env so the
$EVENT_DISPATCHER_TOKEN reference resolves:
export $(grep -E '^EVENT_DISPATCHER_TOKEN=' .env | xargs)
Then POST to a trigger’s webhook (the JSON body is passed to the agent as
untrusted payload):
curl -X POST http://localhost:8020/webhook/hello-dispatch \
-H "Authorization: Bearer $EVENT_DISPATCHER_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'
To see a payload reach the agent, fire triage-event with a realistic body:
curl -X POST http://localhost:8020/webhook/triage-event \
-H "Authorization: Bearer $EVENT_DISPATCHER_TOKEN" \
-H "Content-Type: application/json" \
-d '{"signal":"demo:vacuum:pressure","value":4.2,"threshold":3.0,"severity":"warning"}'
Watch runs stream live on the dashboard at http://localhost:8020/dashboard, or
in the EVENTS tab of osprey web.
The EVENTS Panel#
Projects built from the control-assistant preset surface this dashboard as
an EVENTS tab inside the web terminal, so osprey web exposes it without a
separate browser window. The tab health-gates itself: while the dispatcher is
down it shows unavailable rather than a broken frame, and turns live once the
dispatcher answers /health.
The panel points at ${EVENT_DISPATCHER_URL:-http://localhost:8020}, which
works out of the box for the host-run flow above. When the web terminal runs in
a container, repoint it at the dispatcher service:
EVENT_DISPATCHER_URL=http://event-dispatcher:8020
Authentication#
Two bearer tokens live in the project .env. osprey deploy up
auto-generates a strong random value for each when it is unset (and logs where
it wrote it), so a containerized deploy is secure by default — no editing
required. Set your own values in .env to override:
EVENT_DISPATCHER_TOKEN— guards inbound webhook and write endpoints. Send it asAuthorization: Bearer <token>.DISPATCH_WORKER_TOKEN— guards the dispatcher → worker calls.
How the tokens work
The dispatcher fails closed (HTTP 503) if EVENT_DISPATCHER_TOKEN is
unset — it never accepts an empty token. The dashboard read endpoints (run
feed, trigger list, state, SSE stream) are gated by the same token: the
in-terminal EVENTS tab injects it server-side so the browser never holds it,
while the standalone dashboard receives it via a one-time URL-fragment
handoff.
To call the API by hand, read the generated token back from .env (as in
Fire a Trigger above):
export $(grep -E '^EVENT_DISPATCHER_TOKEN=' .env | xargs)
See also
- Container Deployment
Container deployment mechanics for all Osprey services.
- CLI Reference
Full
osprey buildandosprey deployreference.