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. 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: both images install Node and the agent CLI the worker runs on.
Image build & overrides
The two services use two different images, both built locally on first
osprey deploy up:
the dispatcher gets its own small image (
<project>-dispatch:local, fromservices/event_dispatcher/Dockerfile);the worker runs the full project image (
<project>:local— the same image Containerize a Project describes, with your overlays anddata/baked in), so the agent it launches sees the real project.
Pass --dev to install your local osprey checkout (incl. unreleased
code) via a wheel; otherwise the images install osprey-framework from
PyPI. To use prebuilt/published images instead of building, set the
override env vars — note they take different kinds of image: the worker
override must be a project-style image containing /app/<project>, not
a dispatch image:
OSPREY_DISPATCH_IMAGE=my-registry/osprey-dispatch:dev \
OSPREY_WORKER_IMAGE=my-registry/my-project: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 the tab is disabled with an offline indicator rather than showing a broken
frame, and it 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
Auto-derived URL#
You do not have to set web.panels.events.url by hand. Whenever a profile
lists the events panel and declares a dispatch: block, the build
derives the panel’s route from dispatch.dispatcher_port:
web.panels.events.url: http://localhost:<dispatcher_port> # bare host
web.panels.events.path: /dashboard # route
The url is the bare dispatcher host and path carries the /dashboard
route — the web terminal composes the backend target as url + path, so
baking /dashboard into url would double-prefix sub-routes. Keep them
split. If a profile already pins web.panels.events.path the build leaves it
untouched, and an explicit web.panels.events.url override always wins (use it
for remote/containerized terminals that cannot reach localhost).
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 needs no token editing. 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.