Tasks and runners
Messages go from agents to you; tasks go from you to agents. An agent registers a destination describing what it can do, you dispatch a task to it from the app, and a runner executes it and reports logs and a result.
Destinations
{
"key": "laptop",
"transport": { "kind": "pull" },
"types": [
{
"id": "run",
"label": "Run",
"fields": [{ "id": "prompt", "type": "textarea", "label": "Prompt", "required": true }]
}
]
}
herald register -i destination.json
herald register --target laptop --type run:Run --field 'prompt:textarea:Prompt'
The second line registers the same destination from flags: --type ID[:LABEL] adds a type and each --field after it adds a field to that type (--webhook URL for a webhook transport). Registering the same key again updates that destination. A destination takes its connection's name. Each type has the same form fields as requests, except secrets and files. kind: "session" types describe agent sessions with modes, models and commands; the receiving adapter maps them.
Pull runners
A pull runner claims queued tasks over HTTPS, so it works behind NAT and firewalls. The CLI includes one:
herald listen --target laptop --config runner.json
{
"command": "/usr/local/bin/my-agent",
"args": [],
"cwd": "/srv/project",
"env": ["HERALD_TOKEN"],
"timeout": 3600
}
Each task's JSON goes to the command's stdin (no shell); its output streams back as task logs and its exit status completes or fails the task. --concurrency N runs up to 16 at once. The command gets only PATH, HOME, TMPDIR, the variables named in env, and HERALD_TASK_ID/HERALD_RUN_ID.
To write your own, claim with POST /tasks/:id/claim {runnerId, heartbeat: true}, renew with heartbeat every 60 seconds (a lapsed lease re-queues the task after 5 minutes, and a task fails after its third claim), stream update {lease, logs} chunks of up to 4,096 characters, then update {lease, status: "completed" | "failed", error?}. The SDKs wrap each call: claimTask, heartbeatTask and updateTask.
Webhooks
On Starter and Pro, a destination can use {"kind": "webhook", "url": "https://…"} (public HTTPS). Registration returns signingSecret once; POST /destinations/:id/secret rotates it.
Each task is POSTed as {id, type: "task.created", task} with headers:
Herald-Timestamp: Unix seconds.Herald-Signature: hex HMAC-SHA256 oftimestamp + "." + bodywith the signing secret.Herald-Delivery: a delivery ID for de-duplication.
Verify the signature over the exact body bytes and reject timestamps older than five minutes; verifyWebhook (TypeScript) and verify_webhook (Python) do both. Answer 2xx quickly and do the work elsewhere. Failed deliveries retry with backoff for 24 hours; nothing is exactly-once.
import { verifyWebhook } from '@herald-run/sdk';
const event = verifyWebhook(rawBody, timestamp, signature, process.env.WEBHOOK_SECRET!);
console.log(event.task.input?.values);
Task records
A task is a message with direction: "outbound", input: {destinationId, typeId, values, session?} and, when finished, result: {status, completedAt, error?}. Statuses go queued → running → completed or failed. Read logs with GET /messages/:id/logs or herald logs ID.