A husk becomes a bot through triggers declared in its spec. husk serve reads them and
mounts what it can.
triggers:
- type: http
- type: cron
schedule: '*/15 * * * *'
prompt: Any red builds on main in the last 15 minutes?
- type: webhook
path: /gh
secret: ${GITHUB_WEBHOOK_SECRET}curl -s localhost:7377/v1/triggers | jqWhat is actually mounted
The spec accepts seven trigger types. TriggerHost.sync() has branches for three.
| Type | Mounted | Where |
|---|---|---|
http | yes | ALL /v1/t/<husk><path> |
webhook | yes | POST /v1/w/<husk><path> |
cron | yes | The in-process scheduler |
cli | n/a | It is the default; it means "no server surface", which is correct |
discord | no | — |
slack | no | — |
telegram | no | — |
http
triggers:
- type: http
path: /triage # default '/'
auth: token # default 'token'Mounted at /v1/t/<husk-name><path> and it answers any method.
curl -X POST localhost:7377/v1/t/ci-triage \
-H 'content-type: application/json' \
-d '{"input":"why is main red?"}'The prompt is extracted in this order:
- Query
?input=,?text=,?q= - A raw string or
Bufferbody - JSON keys
input,text,message,prompt,content,query - Otherwise, the whole JSON body stringified
No prompt anywhere is a 422 with no prompt found in the request.
Stream instead of buffering with Accept: text/event-stream or ?stream=true; you then
get RunEvent frames.
{ "runId": "run_x", "text": "…", "stopReason": "complete", "usage": { } }webhook
triggers:
- type: webhook
path: /gh
secret: ${GITHUB_WEBHOOK_SECRET}Mounted at POST /v1/w/<husk-name><path>. With a secret, the body is verified as
HMAC-SHA256 over the raw bytes, and the signature is read from the first of
X-Husk-Signature, X-Hub-Signature-256, X-Signature-256, with a leading sha256=
stripped. A mismatch is a 403 E_EXEC_DENIED.
Without a secret, GET /v1/triggers reports the mount as
UNSIGNED -- set 'secret' on the trigger. Anyone who can reach the port can spend your
model budget.
Prompt extraction is the same as for http.
cron
triggers:
- type: cron
schedule: '*/15 * * * *'
prompt: Any red builds on main in the last 15 minutes? If none, reply exactly "clear".Both schedule and prompt are required; the schema gives them no defaults, because a
cron trigger with nothing to say is not a trigger.
Standard five-field cron. The scheduler runs in the husk serve process — there is no
external cron and no persistence, so a missed window while the server was down is not
replayed. An invalid schedule is logged as husk <name>: <message> and that one job is
skipped; the rest of the husk still mounts.
GET /v1/triggers reports each binding's next fire time, or never fires.
cli
The default when a spec declares no triggers. It means the husk is run with husk run
and exposes no server surface, which is the right default for something you have just
distilled and not yet reviewed.
Idempotency
The control plane honours Idempotency-Key on POST, PUT, PATCH and DELETE,
including on the trigger paths. A replay returns the recorded status and body with
Idempotency-Replayed: true.
The cache is in memory with a 15-minute TTL, so it does not survive a restart, and it does not cover streaming responses — an SSE reply is never recorded, so a retried streaming request runs the agent again.