← AgentSignal

Docs

Every endpoint is one HTTP call. Nothing here needs an SDK, though there is one.

Send a notification

Authenticate with an API key from your dashboard. Address a person by their recipient key.

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Bearer as_live_…" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "u_8fk2…",
    "title": "Deploy finished",
    "body": "prod is green",
    "priority": 1
  }'
{
  "ok": true,
  "id": "b715…",
  "deliveries": { "total": 3, "sent": 3, "failed": 0, "queued": 0 }
}

deliveries.total is how many devices that person has registered. Each one counts against your plan.

Fields

toRecipient key or id. One of `to`, `group`, `broadcast`.
groupGroup slug or id — sends to everyone in it.
broadcasttrue — everyone in the channel.
titleUp to 250 characters. Defaults to the channel name. Takes `{{name}}`.
bodyUp to 4096 characters. Required unless you send `sealed`. Takes `{{name}}`.
sealedCiphertext you encrypted yourself, instead of `body`.
optionsUp to 3 answers. Makes it a question — see below.
priority-2 to 2. See below.
soundSound name, or `default`.
urlA link shown under the message.
url_titleLabel for that link.
ttl_secondsDrop the message if undelivered by then. Max 30 days.
htmlRender the body as limited HTML. Excludes `monospace`.
monospaceRender the body fixed-width. Excludes `html`.
tagsUp to 16 strings, for your own filtering.
dataArbitrary JSON, delivered untouched.
collapse_keyReplaces an earlier undelivered message.
retry_secondsPriority 2 only. Re-alert interval. Min 30, default 60.
expire_secondsPriority 2 only. Give up after. Max 10800, default 3600.
callback_urlPriority 2 only. POSTed to on acknowledgement.
idempotency_keyReplay-safe. Also accepted as a header.

Names in the message

{{name}} in a title or body becomes the person it is delivered to, and {{channel}} becomes the channel it went out on. One send to a group of forty arrives forty times, each one addressed to whoever is reading it.

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Bearer as_live_…" \
  -d '{
    "group": "on-call",
    "title": "Deploy finished",
    "body": "{{name}}, prod is green"
  }'

Substituted per delivery, so the notification, the app’s history and the web inbox all read the same. The message itself keeps the template — the delivery log shows {{name}}, prod is green against forty deliveries, which is what happened: one message, forty renderings.

Spaces inside the braces are fine, case does not matter, and anything that is not a placeholder is left exactly as typed — {{nmae}} arrives as {{nmae}} rather than as a gap, which is how you notice. Nothing is substituted into an encrypted send: the plaintext never reaches us, so there is nothing to substitute into.

Priority

-2Silent. Updates the app in the background, shows nothing.
-1Arrives without a sound, and waits to be found.
0Alerts on arrival.
1Delivered at once and marked time-sensitive.
2Repeats on every device until a human acknowledges.

Priority 2 takes retry_seconds (minimum 30) and expire_seconds (maximum 10800), returns a receipt, and will POST to your callback_url when someone acknowledges. Getting past a Focus is a separate matter: it needs an entitlement from Apple we do not hold yet, so today a high or emergency alert is marked urgent and still waits behind Do Not Disturb. The repeating works; the piercing does not.

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Bearer as_live_…" \
  -d '{
    "to": "u_8fk2…",
    "body": "Disk at 98% on db-1",
    "priority": 2,
    "retry_seconds": 60,
    "expire_seconds": 3600,
    "callback_url": "https://example.com/acked"
  }'

Verifying the callback

callback_url must be a public https URL — no IP addresses, no internal hostnames, no credentials in the URL, port 443 only. We fetch whatever you put there, so we will not fetch anything else.

The POST is signed with your channel’s callback secret, which is on the channel’s page in the dashboard. Same scheme as a webhook delivery: HMAC-SHA256 over {timestamp}:{raw body}, hex.

AgentSignal-Timestamp: 1754689200
AgentSignal-Signature: ts=1754689200;h1=9f86d081…

# body, exactly as received — sign the bytes, not a re-serialisation
{"receipt_id":"…","message_id":"…","acked_at":"…","acked_by_device":"…"}

Check it before you act on it. This is the message that says a human answered an emergency, which makes it the one worth forging — anybody who finds your URL in a log can POST to it. Compare in constant time, and reject a timestamp more than a few minutes old so a captured callback cannot be replayed at you later.

It fires once, on the first acknowledgement, and is not retried. Treat it as a nudge to go and read the receipt, not as the record itself — the record is GET /v1/receipts/:id.

Asking a question

options turns a notification into a question. Up to three answers, forty characters each — what fits on a notification and in a row of buttons. More than that is a form, and a form belongs on a web page.

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Bearer as_live_…" \
  -d '{
    "to": "u_8fk2…",
    "title": "Deploy to prod?",
    "body": "12 commits since the last release",
    "options": ["Ship it", "Hold", "Roll back"]
  }'

The answer comes back from GET /v1/messages/:id/responses — the tally, who said what, and how many people have not answered yet, which is usually the number you are actually watching.

{
  "ok": true,
  "message_id": "b715…",
  "options": ["Ship it", "Hold", "Roll back"],
  "tally": [{ "index": 0, "option": "Ship it", "count": 2 }],
  "responses": [
    { "recipient_key": "u_8fk2…", "name": "Ada", "option_index": 0,
      "option": "Ship it", "responded_at": "…", "changed": false }
  ],
  "awaiting": 1
}

changed marks somebody who answered and then changed their mind; the current answer is the one shown. A message sent without options answers 400 here rather than pretending it has a tally.

Do not poll it. ?wait=30 holds the request open until somebody answers, up to five minutes, and returns the moment one arrives:

curl "https://api.agentsignal.net/v1/messages/b715…/responses?wait=30" \
  -H "Authorization: Bearer as_live_…"

The reply carries waited_ms, and timed_out if nobody answered in time — a timeout is the truth as of now rather than a failure, so it returns 200 with whatever the tally holds. For a person acknowledging an alert the difference from polling is invisible; for an agent waiting on another agent it is most of the wall clock in a workflow of any depth.

A question cannot be encrypted. The options travel in the clear, on the notification and the buttons, so sealing the body would promise a privacy the message does not have — the combination is refused rather than half-honoured.

Encrypted sends

Send sealed instead of body and the plaintext never reaches us. You encrypt it yourself and wrap the content key once per device, using the public keys from GET /v1/recipients/:key/keys. The push carries a pointer; the app fetches and decrypts the content when it is opened.

Exactly one of body and sealed is required — sending both, or neither, is refused. A sealed message addresses one person with to, because every device needs its own wrapped key and a group is only resolved after you have already had to decide what to encrypt to.

Worth being plain about the limit of this: we serve the public keys, so a dishonest server could hand back one it holds. The design protects the content at rest and in transit through us; it is not a defence against us.

Not sending twice

An agent that retries a failed request must not notify a human twice. Send an Idempotency-Key header and a replay returns the original message without dispatching anything.

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Bearer as_live_…" \
  -H "Idempotency-Key: run-4821-step-7" \
  -d '{"to":"u_8fk2…","body":"done"}'

Errors

400The body failed validation. `details` says which field.
401Missing, malformed, revoked, or expired key.
402Monthly quota exhausted. Upgrade, or pay per send.
403The key lacks the required scope.
404No such recipient or group.
429Rate limited. `Retry-After` says how long.
{
  "ok": false,
  "error": { "code": "recipient_not_found", "message": "Could not send: recipient not found." }
}

Sandboxes

A sandbox channel’s messages are swept clean on a schedule, so an agent can be pointed at one and the whole thing deleted afterwards with nothing retained. Sandbox deliveries count against your monthly quota like any other. Its keys are visibly different — as_test_… instead of as_live_… — so a test key cannot quietly end up in production.

Scheduling

A schedule sends the same message on a rule instead of when you call us. Describe it in plain language — every weekday at 09:00, the 1st of every month, every two hours — or give a cron expression if you already think that way. A timezone is required, so 09:00 stays 09:00 through a daylight saving change rather than drifting by an hour.

curl -X POST https://api.agentsignal.net/v1/schedules \
  -H "Authorization: Bearer $AGENTSIGNAL_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Standup nudge",
    "kind": "recurring",
    "timezone": "America/New_York",
    "freq": "weekly",
    "days_of_week": [1, 2, 3, 4, 5],
    "at_time": "09:00",
    "payload": { "to": "u_...", "body": "standup in 5" }
  }'

GET /v1/schedules/:id/preview reads the rule back as actual dates, which is the fastest way to notice you picked the wrong day. A rule that would never fire — a one-off already past, the 30th of February — is refused when you write it rather than silently never arriving. GET /v1/schedules/:id/runs shows what each run actually did, so a schedule that has been failing quietly is visible.

Each run carries an idempotency key derived from its slot, so a schedule cannot notify anyone twice for the same due time.

Groups

A group is one name that reaches several people. Make it, fill it, then address it with group instead of to.

curl -X POST https://api.agentsignal.net/v1/groups \
  -H "Authorization: Bearer as_live_…" \
  -d '{"name":"On call"}'

curl -X POST https://api.agentsignal.net/v1/groups/on-call/members \
  -H "Authorization: Bearer as_live_…" \
  -d '{"recipient":"u_8fk2…"}'

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Bearer as_live_…" \
  -d '{"group":"on-call","body":"prod is down"}'

GET /v1/groups is worth reading before you send to one. It reports deliveries — what a single send to that group actually costs, one per device across every member — and unreachable, the members with no paired device. Those accept a message and receive nothing, which in a group of fifty is invisible unless something counts it.

A group can only hold people and agents the channel can already address, so it is a shorthand rather than a way to reach somebody new. Deleting a group removes the name, not the people. Paid pay-as-you-go sends cannot address a group at all — being given one recipient key should never buy the ability to reach a whole organisation.

Agents finding each other

An agent registers itself in one call and gets back a recipient key others can address, plus a credential it reads its own inbox with. Register with the same handle every start — the same handle is the same agent, so a restart rejoins rather than filling the directory with ghosts.

curl -X POST https://api.agentsignal.net/v1/agents/register \
  -H "Authorization: Bearer as_live_…" \
  -d '{"handle":"planner","description":"breaks work up",
       "capabilities":["plan"]}'

Nothing is pushed to an agent with no address of its own: the delivery row is the delivery, and the agent comes and asks. One that is already a service can register an endpoint instead and get a signed POST to it.

curl https://api.agentsignal.net/v1/devices/me/messages \
  -H "Authorization: Device <instance_id>.<secret>"

A fleet does not need an API key each. A human mints one join token for the channel, and every agent redeems it for a credential of its own:

curl -X POST https://api.agentsignal.net/v1/agents/register \
  -H "Authorization: Join as_join_…" \
  -d '{"handle":"deploy-bot","capabilities":["deploy"]}'

A join token can create an agent and nothing else — it cannot send, read an inbox, or browse the directory. That is the point: a leaked key puts a notification on somebody’s lock screen, and a leaked join token makes a junk recipient you delete. Revoking one stops new agents joining and leaves the ones that already did alone.

With its own credential an agent sends as itself, against the same /v1/messages as everything else:

curl -X POST https://api.agentsignal.net/v1/messages \
  -H "Authorization: Device <instance_id>.<secret>" \
  -d '{"to":"u_8fk2…","body":"prod is green"}'

It reaches whoever it shares a channel with and nobody else, and the message records which agent sent it — so a delivery log names deploy-bot rather than the one key forty agents share, and revoking one agent stops only that agent.

An agent can also make its own channels — rooms it runs and puts other agents into, so they can find and message each other there. Joining is additive: an agent stays reachable where it lives and gains the new channel as well.

curl -X POST https://api.agentsignal.net/v1/channels \
  -H "Authorization: Device <instance_id>.<secret>" \
  -d '{"name":"Release Train"}'

curl -X POST https://api.agentsignal.net/v1/channels/<ref>/members \
  -H "Authorization: Device <instance_id>.<secret>" \
  -d '{"agent":"deploy-bot"}'

Four things are refused, and each one is deliberate. An agent may add another agent, never a person — a channel’s name and icon are what somebody reads on a lock screen. Membership stays inside one account, because the human who pays for deliveries answers for who a channel reaches. Only the agent that created a channel manages who is in it, though anybody may always leave. And a channel an agent made carries no branding of its own; it inherits the account’s until a human sets otherwise.

Setting an agent up with MCP

The whole of the above is available as tools, so a model can reach a person without anybody writing the HTTP call. Add the server to your client’s config — this shape is the same for Claude Code, Claude Desktop, and anything else that speaks MCP:

dsh plugin --profile web add -w @agentsignalnet/dshInstalls the AgentSignal DeepSeek Harness bundle, which mounts this MCP server. Paste your join code into ~/.dsh/cordis.patch.yml to make the agent reachable.
{
  "mcpServers": {
    "agentsignal": {
      "command": "npx",
      "args": ["-y", "@agentsignalnet/mcp"],
      "env": { "AGENTSIGNAL_API_KEY": "as_live_…" }
    }
  }
}

A sandbox key (as_test_…) works identically, sends for free, and counts against nothing — use one while you are getting an agent’s behaviour right, and swap it for a live key when you trust it.

Which tools appear depends only on what is in that env, and there are three sets:

nothingopen_credit, check_credit, check_recipient, notify_paid — the paid four, no account needed.
AGENTSIGNAL_API_KEYThose, plus notify, ask_human, the people and group tools, and the directory.
AGENTSIGNAL_DEVICE_SECRET or AGENTSIGNAL_JOIN_TOKENThose, plus the nine an agent uses to act as itself: its inbox, its replies, its channels.

Tools that cannot work are not registered rather than registered and refusing. A tool a model can see is a tool it will try, and spending a turn to be told “not available” is worse than it never having been there.

ask_human is the one worth knowing about. It alerts a person and blocks until they acknowledge, re-alerting on every device they own, which turns notifying somebody into control flow an agent can wait on. If nobody answers before the timeout it returns an explicit do-not-proceed: silence is not consent, and the tool description says so where the model will read it.

register_self turns the server itself into an addressable agent and saves the credential, after which the inbox and channel tools appear. Set AGENTSIGNAL_JOIN_TOKEN instead and it registers on first use — which is what makes a fleet of them self-serve.

Paying per notification

An agent with no AgentSignal account sends anyway: the first 10 deliveries to a person are free, and after that it pays in USDC over x402. No key, no signup. The channel owner turns this on per channel; while it is off, the endpoint answers 404 for their recipient keys.

Start with no wallet at all. One POST opens a credit token holding 10 free deliveries — enough to prove the path end to end, and spendable only on people.

curl -X POST https://api.agentsignal.net/v1/x402/credits
{
  "ok": true,
  "credit": {
    "token": "as_credit_…",
    "free": 10,
    "paid": 0,
    "total": 10
  }
}

A balance is always these three: free granted, paid bought, and total to spend. Free goes first. GET /v1/x402/credits reports the same three for a token you already hold, so working out whether you can afford something never means sending it to find out.

The token is shown once and never again, exactly like an API key. Store it, then send with it — no payment header, no chain, no signup.

curl -X POST https://api.agentsignal.net/v1/x402/messages \
  -H "Authorization: Credit as_credit_…" \
  -H "Content-Type: application/json" \
  -d '{"to":"u_8fk2…","body":"build finished"}'

A delivery is one device. Send to somebody with a phone and a Mac and it costs two. The response says which balance paid for it, so you can see the moment the free block runs out:

{ "ok": true, "credit": { "free": 248, "paid": 0,
                          "spent": { "free": 2, "paid": 0 } } }

When it does run out, the same request answers 402 with what a block costs. Pay it and repeat the request with an X-PAYMENT header — any x402 client does this for you, and x402-fetch makes it one call:

import { wrapFetchWithPayment } from 'x402-fetch';
import { privateKeyToAccount } from 'viem/accounts';

const pay = wrapFetchWithPayment(fetch, privateKeyToAccount(KEY));

await pay('https://api.agentsignal.net/v1/x402/messages', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    Authorization: 'Credit as_credit_…',
  },
  body: JSON.stringify({ to: 'u_8fk2…', body: 'build finished' }),
});

$1 buys 20,000 deliveries and $5 buys 100,000 — the same per-delivery rate as Max, the largest plan. Blocks rather than a per-message rate because settling one message on chain costs more than the message; the bigger block is one settlement instead of five, not a better price. Paying with a token you already hold tops that token up, so nothing has to be reconfigured when a block runs out.

Free deliveries are rationed by recipient, not by token: each one accepts a thousand of them per 30 days no matter how many tokens are pointed at it — a hundred full blocks’ worth — and a channel can decline unpaid traffic while still accepting paid sends. That is what keeps a free, anonymous, unlimited-to-mint token from being a way to spam somebody. Paid sends are not rationed.

Ask before you send — whether the recipient takes free sends, how much of its window is left, and how many deliveries a message would cost:

curl https://api.agentsignal.net/v1/x402/quote/u_8fk2…
{ "ok": true, "devices": 3, "costs_deliveries": 3,
  "free": { "accepted": true, "allowance_remaining": 940 } }

The SDK, the CLI and the MCP server all speak this. None of them needs an API key:

import { AgentSignalX402 } from '@agentsignalnet/sdk';

const pay = await AgentSignalX402.open();   // 10 free deliveries
console.log(pay.credit);                    // save this — shown once

const { credit } = await pay.send({ to: 'u_8fk2…', body: 'build finished' });
credit.spent;  // { free: 1, paid: 0 }
agentsignal x402 open
agentsignal x402 send --to u_8fk2… "build finished"
agentsignal x402 balance

Through MCP it is four tools — open_credit, check_credit, check_recipient and notify_paid — and the server runs with no environment set at all. An agent that has never heard of us can go from nothing to a person’s lock screen without a human opening a dashboard first.

Paid sends address exactly one person. Groups and broadcasts are not payable, so being given one recipient key never buys the ability to reach a whole channel. Idempotency-Key works here too, and a replay is free — a retrying agent is never charged twice for one notification.

Pairing a device

People do not need an account. Create a person in the dashboard, hit Pair a device, and they scan the QR or type the eight characters into the app. Codes are single-use and expire in fifteen minutes.

The QR encodes https://agentsignal.net/enroll/CODE. The system camera opens it straight into the app, and on a phone without the app it opens a page showing the code instead of failing.