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 of timestamp + "." + body with 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.