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:
| Resource | What reading it gives you |
|---|---|
neutron://campaigns | Every campaign on this instance with its state and counts. |
neutron://inbox/unanswered | The Inbox threads still waiting on us, across every channel. |
neutron://sends/held | Letters a gate stopped, with the reason each one was held for. |
Two are parameterised and answer resources/templates/list:
| Template | What reading it gives you |
|---|---|
neutron://campaign/{id}/overview | One campaign’s figures and the attention it is asking for. |
neutron://work/{project}/board | A 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.
-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.
| Event | What has just happened | What the body carries |
|---|---|---|
reply.landed | Somebody wrote back. | The touch, the person, the campaign, the channel, and the reading. |
send.held | A letter did not go. | The send, the campaign, the person, and the reason it is held. |
domain.paused | The warden paused a domain on evidence. | The domain and the reason. |
deal.opened | A reply was read as positive and a deal exists. | The deal, the campaign, the person, the prospect and the state. |
work.review_parked | A 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:
| Header | What it holds |
|---|---|
X-Neutron-Event | The event kind. |
X-Neutron-Delivery | The delivery’s own id, which is what you deduplicate on. |
X-Neutron-Signature | sha256= followed by an HMAC-SHA256 of the exact request body, keyed with the signing secret. |
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.
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.