Skip to content

6 · Events and resources

What you will have done: learned how an agent finds out that something happened, without asking every minute.

Why polling is the wrong shape

An agent that wants to know when a reply lands could call mail_search on a timer. Ask often and most calls learn nothing; ask rarely and the news is stale. Either way the instance is answering a question nobody needed answered.

Two surfaces fix it from opposite ends. A resource is a thing you can read by name instead of composing a query. An event is the instance telling you, once, that something changed.

    flowchart LR
  subgraph Pull
    A["Your agent"] -->|"resources/read"| B["neutron://campaign/{id}/overview"]
    B -->|"the current state, by name"| A
  end
  subgraph Push
    C["Something happens"] --> D["The instance"]
    D -->|"signed POST"| E["Your receiver"]
    D -->|"GET /api/admin/events/recent"| F["Polling, when you cannot listen"]
  end
  

Resources

initialize advertises them alongside tools. Three are concrete and answer resources/list:

ResourceWhat reading it gives you
neutron://campaignsEvery campaign on this instance with its state and counts.
neutron://inbox/unansweredThe Inbox threads still waiting on us, across every channel.
neutron://sends/heldLetters a gate stopped, with the reason each one was held for.

Two are parameterised and answer resources/templates/list:

TemplateWhat reading it gives you
neutron://campaign/{id}/overviewOne campaign’s figures and the attention it is asking for.
neutron://work/{project}/boardA Projects board: its columns and the cards standing in them.

Everything comes back as JSON, capped at the same 48,000 characters a tool result is capped at.

A resource read dispatches into the route the screens use, as the token’s own principal, and the id you supply is pushed through the same path normalizer the tools use. A crafted URI therefore reaches no path a tool could not, and fails closed rather than leaning on the router to normalise it first.

A refusal comes back as an error, not as contents. If the route answers 403, the read fails with -32002 saying so. It does not hand you the refusal as the campaign’s body, because a client reading a resource has nowhere to put “HTTP 403” and would file it as the campaign.

Events

Five things an instance will tell you about, each the moment it happens.

EventWhat has just happenedWhat the body carries
reply.landedSomebody wrote back.The touch, the person, the campaign, the channel, and the reading.
send.heldA letter did not go.The send, the campaign, the person, and the reason it is held.
domain.pausedThe warden paused a domain on evidence.The domain and the reason.
deal.openedA reply was read as positive and a deal exists.The deal, the campaign, the person, the prospect and the state.
work.review_parkedA work item moved to done and parked in review.The item, the project, the title, the approval and who moved it.
reply.landed carries the reading, and never the message. It names which of the seven readings the inbox gave the reply, and there is no body, subject or snippet field anywhere in it. On WhatsApp the stored label is the message, so anything that is not one of the seven readings is sent as unread rather than passed through. A letter does not leave the building because it happened to be short enough to look like a label.

Setting up a receiver

Under Settings › Revenue › Integrations, the Events card takes the address this instance posts to. Setting it needs the wire scope on a token, because naming the address decides where this instance’s activity goes.

Turning it on without naming a secret mints one and shows it once, because a delivery nobody can verify is not worth sending. Rotating it shows the new one once too, and neither is ever readable back from a screen.

Each delivery carries three headers:

HeaderWhat it holds
X-Neutron-EventThe event kind.
X-Neutron-DeliveryThe delivery’s own id, which is what you deduplicate on.
X-Neutron-Signaturesha256= followed by an HMAC-SHA256 of the exact request body, keyed with the signing secret.
Verify the signature on every delivery. Four of the five events are things somebody would like to tell you falsely, and an endpoint that accepts an unsigned body is an endpoint anybody who learns its URL can write to. The signature is what makes an event evidence rather than a claim.

Where a receiver may sit

The URL is operator input that this instance then fetches, and the last error is readable on the recent log. An unchecked address would therefore be a port scanner that reports its findings every thirty seconds, so the address is checked.

Refused: loopback, 0.0.0.0/8, the IPv6 unspecified address ::, link-local, unique local, the RFC 1918 ranges, carrier-grade NAT, multicast, the bare name localhost, and any name ending .internal, .local or .localdomain. localhost and those suffixes are refused by name, before anything is resolved. Every other name is resolved and its addresses are checked, so the spelling is not what clears it. Credentials in the URL are refused too, whatever else is set, because they would be sent to whatever the name resolves to and a signed delivery needs no second credential.

Two details follow from the reason rather than the rule.

It is checked twice. Once when the address is set, and again on the pass that sends, because a name that answered publicly once can answer 127.0.0.1 the next time it is asked. The second check runs once for the pass rather than once per row, and a refusal there fails every row in that batch of up to fifty. That is deliberate: a name answering privately is a misconfiguration somebody has to correct, so it spends the retry ladder rather than retrying for ever.

Redirects are reported, not followed. A redirect would carry the body and the signature to a host you never named, and the check that cleared the first host says nothing about the second.

A hostname that resolves to nothing is allowed through. You may configure a receiver before its DNS exists, and a name that does not resolve is a delivery that fails on its own rather than one that reaches anything.

If your receiver really is a sidecar on the same network, set NEUTRON_EVENTS_ALLOW_PRIVATE=1 on the instance. That is the opt-out, and it is deliberately an environment variable rather than a checkbox: it is a deployment decision, not a per-session one. Credentials in the URL stay refused either way.

When a delivery fails

A non-2xx answer or a timeout is a failure, and a failure is retried three times: after one minute, after five, and after thirty. A fourth failure ends it. The row stays as the record of what was missed, carrying the last error.

Two cases are deliberately not retries. With a URL set but no signing secret, deliveries fail rather than going out unsigned. With no URL at all, events are marked skipped as they happen rather than queuing, because an operator who wires a receiver up today did not ask for last week’s events to arrive at once.

If you would rather not run a listener, GET /api/admin/events/recent reads the same rows newest first.

What an event is not

An event says a thing happened. It does not carry permission to act on it, and it does not change what the tools will let you do.

work.review_parked is the clearest case. It tells you an item is waiting on a person. It does not make the agent that receives it eligible to be that person, and calling a tool in response will meet the same refusal it always would.

The useful pattern is narrow: an event wakes an agent, the agent reads the current state through a tool or a resource, and it acts on what it read. Acting on the body of an event alone is acting on a claim about a moment that has already passed.

Last updated on