Agent contract
What Sift sends an agent for each escalation, and where it reads the agent’s verdict. Read this if you are adapting your own agent to a filter, or checking what a generated agent receives. For why the agent is shaped this way, see The paired agent.
A filter delivers in one of two ways, set by target.delivery in its
settings:
| Delivery | Agent | What it receives |
|---|---|---|
event_blob | One Sift generated for the filter | One input, sift_event, holding the whole event in a JSON envelope. |
named_inputs | An existing agent someone else wrote | Its own declared inputs, each filled from a template. |
The sift_event input
Section titled “The sift_event input”A generated agent declares one input. Its template name and its display name are both
sift_event. The agent’s first step carries it between two marker lines:
----- BEGIN SIFT EVENT DATA (untrusted) -----{{userInput:sift_event}}----- END SIFT EVENT DATA -----The value is one JSON document with two top-level keys: sift, Sift’s metadata about the
delivery, and event, the source payload exactly as the source returned it. Sift sends
it compact, with keys sorted and sift first; the examples below are indented for
reading.
A single event
Section titled “A single event”{ "sift": { "delivery": { "attempt": 1, "resend": null, "sent_at": "2026-10-14T09:30:12Z" }, "event_id": "5f0c2a9e-3b1d-4c7e-9a41-2d6f8e1b7c30", "filter_id": "flt_8k2m4q", "filter_version": 3, "represents_events": 1, "source_event_id": "SEC-4821", "truncated": null }, "event": { "fields": { "created": "2026-10-14T09:29:58Z", "priority": "High", "summary": "Impossible travel: login from two countries" }, "key": "SEC-4821" }}Envelope keys
Section titled “Envelope keys”The sift object carries these keys and no others.
| Key | Type | Meaning |
|---|---|---|
filter_id | string | The filter that sent the event. |
filter_version | integer | The filter’s version when it was sent. |
event_id | string | Sift’s id for the event. Use it to check whether you already acted on the event. |
source_event_id | string | The event’s id in the source (the filter’s source.id_field). |
represents_events | integer | How many events this one stands for, itself included. 1 for an ordinary event; more when Collapse repeats folded near-identical events into it. |
truncated | object or null | Null when the whole event fits. See Truncation. |
delivery | object | How the event reached the agent. |
delivery:
| Key | Type | Meaning |
|---|---|---|
attempt | integer | 1 for the first send, one more for each send after it. |
sent_at | string | When this send went out, ISO 8601 in UTC. |
resend | object or null | Null unless an earlier send of this event may have started a run. |
history | object | Only on a run of history, to an agent generated to contract 5 or later. Absent otherwise, not null. See History. |
delivery.resend, when present:
| Key | Type | Meaning |
|---|---|---|
first_sent_at | string | When the first send that may have reached the agent went out. |
earlier_sends | integer | How many earlier sends may have reached it. Sends that certainly did not (refused, never connected) are not counted. |
reason | string | Why, in Sift’s words. |
{ "attempt": 2, "resend": { "earlier_sends": 1, "first_sent_at": "2026-10-14T09:30:12Z", "reason": "The response to an earlier send was lost (read timeout), so a run may already exist for this event." }, "sent_at": "2026-10-14T09:30:52Z"}Kindo does not de-duplicate runs, so a marked re-send asks the agent to check, before any
action with effects outside the conversation, whether that action was already taken for
this event_id. The verdict is the same either way.
The envelope never carries the event’s score, the threshold, the model, or whether the event was escalated or sent as part of the leak-through sample. A leak-through event and an escalation of the same event render the same bytes.
History
Section titled “History”Some runs are of events that were in the source before the filter went live:
- every event a warm start sends;
- the events a filter’s first poll reads. That poll has no place to start from, so it reads the backlog the source already holds, up to 20 pages and 1,000 events. After a warm start, the first poll starts where the warm start’s read ended, so its backlog is small or empty.
To an agent generated to contract 5 or later, such a run carries delivery.history:
| Key | Type | Meaning |
|---|---|---|
observed_at | string or null | When the event happened, by the source’s time field. Null when the filter names no time field or the value could not be read. |
read_at | string | When Sift read it from the source. |
{ "attempt": 1, "history": { "observed_at": "2026-10-01T22:15:00Z", "read_at": "2026-10-14T09:12:03Z" }, "resend": null, "sent_at": "2026-10-14T09:30:12Z"}The managed region tells the agent what it asks: judge the event exactly as if it were live, since the verdict is the same either way; take no action with effects outside the conversation (no ticket, no page, no message); and say in the reply that it was history. On a group, the mark is the group’s, and is set only when every event in the group is history.
A live run has no history key, so its bytes are what they were at contract 4. An agent
generated below contract 5, or one Sift did not generate, is never sent the field, and
acts on history as on any other event. A warm start on such an agent asks you to accept
that first.
A group of events
Section titled “A group of events”With Shape delivery grouping, one run can carry 2 to 50 events. A run of one event is always the single envelope above, so an agent generated to an earlier contract keeps working on every filter that does not group. Grouping needs an agent generated to contract 4.
A group has sift.group_id, and events in place of event: the events in the order
they arrived, each with its own sift and event.
{ "sift": { "delivery": { "attempt": 1, "resend": null, "sent_at": "2026-10-14T09:30:12Z" }, "events_in_group": 2, "filter_id": "flt_8k2m4q", "filter_version": 3, "group_id": "7d1e9b4a-60c2-4f8e-b3a5-1c9d0e2f4a68" }, "events": [ { "sift": { "event_id": "5f0c2a9e-3b1d-4c7e-9a41-2d6f8e1b7c30", "represents_events": 1, "slot": "e01", "source_event_id": "SEC-4821", "truncated": null }, "event": { "fields": { "created": "2026-10-14T09:29:58Z", "priority": "High", "summary": "Impossible travel: login from two countries" }, "key": "SEC-4821" } }, { "sift": { "event_id": "a3b7e1d0-92f4-4c6a-8e5b-0f1d2c3b4a59", "represents_events": 12, "slot": "e02", "source_event_id": "SEC-4822", "truncated": null }, "event": { "fields": { "priority": "Low", "summary": "MFA push denied" }, "key": "SEC-4822" } } ]}Group sift key | Meaning |
|---|---|
filter_id, filter_version | As for a single event. |
group_id | Sift’s id for the group. |
events_in_group | How many events the run carries, 2 to 50. |
delivery | The run’s delivery. A re-send sends the whole group again; the agent checks for an action already taken per event_id. |
Each entry’s sift has slot, event_id, source_event_id, represents_events and
truncated. The slots run e01 to e50, in arrival order, and name the verdict field
that answers for each event. The group carries no key it was grouped by.
The agent judges a group event by event, each on its own evidence. Its actions are taken once for each event worth acting on, unless its instructions say once per group.
Truncation
Section titled “Truncation”SIFT_MAX_TRIGGER_INPUT_CHARS (default 32,000) caps one run’s input; a Shape delivery
stage’s max_chars can lower it for group runs.
event_blob. When the document does not fit, sift is kept whole and event
becomes a JSON string holding the start of the event’s JSON. sift.truncated says how
much was left out:
{ "omitted_chars": 300, "total_chars": 425}The document always parses. If the envelope alone is over the cap, it is sent whole with
none of the event. In a group, the envelope comes off the top and each event is
guaranteed an equal share of the rest; room an event does not use passes to the events
after it, so the earliest keep the most. Each event that is cut is cut the same way, and
says so in its own sift.truncated.
named_inputs. The longest input is cut first, repeatedly, and ends with:
[truncated by sift: <omitted> of <total> characters omitted]Either way, the run is recorded as truncated.
The verdict
Section titled “The verdict”The agent’s answer for each event is a disposition:
disposition | Meaning | Sift reads it as |
|---|---|---|
actionable | The event is one the agent’s instructions say to act on. | positive |
benign | It is not. | negative: the only verdict that teaches Sift what can be filtered |
insufficient_data | The run could not tell. | positive: an event nobody could judge still needs an agent |
A run that failed, was cancelled, returned nothing, or gave no disposition Sift could
read is no verdict at all. It is never counted as benign.
Where the agent records it
Section titled “Where the agent records it”Sift reads only the run’s result, which is the reply of the agent’s last step. A verdict recorded in an earlier step never arrives. The agent records it in both of these places:
-
Conversation metadata. A generated agent declares this schema for it (
conversationMetadataSchema):Field Type Required Meaning dispositionactionable,benign,insufficient_datayes The verdict. In a group, the most severe of the group’s: actionable, theninsufficient_data, thenbenign.dispositionsobject, fields e01toe50, each a dispositionno In a group only: the verdict on each event, by slot. rationalestring, up to 4,096 characters no One or two sentences on why. sift_unconfiguredboolean no trueonly while the agent’s instructions are still Sift’s placeholder.Other fields are allowed. When the metadata is written more than once, the latest revision wins.
-
The final message, on a line of its own:
disposition: benignand in a group, each slot on its own line as well:
disposition: actionablee01: actionablee02: benignA JSON object with a
dispositionfield (or adispositionsobject), whole or in a fenced code block, is read too.
Sift reads, strongest first: the metadata, then the final message, then the whole
transcript. For an agent Sift did not generate, it then falls back to a narrow guess from
the final message’s wording, and only when exactly one disposition’s words appear. Each
label records which route it came by, shown on the filter’s screens as Written
(metadata), Stated (the final message) or Guessed (inferred from wording); the API
calls them metadata, structured and inferred. For a group, each slot is read only from the metadata
or a stated line; a slot with neither is an event with no verdict, never a guess.
An unconfigured agent
Section titled “An unconfigured agent”A generated agent imported without anyone filling in its instructions answers
insufficient_data to every event and writes sift_unconfigured: true (in metadata,
and as a line in its reply), because its placeholder tells it to. Sift records such a
run as no verdict, not as a positive, and the filter shows agent.unconfigured. Replacing
the placeholder with real instructions removes the instruction.
Briefing an agent Sift did not write
Section titled “Briefing an agent Sift did not write”The filter’s Overview offers a Briefing for the agent: the verdict rules above as
text to paste into your own agent’s last step (GET /api/v1/filters/{filter_id}/verdict-briefing
returns it). It says how to report a verdict, not how to reach one. Nothing needs
re-pairing; afterwards, the verdict routes on the filter’s screens should move from
Guessed towards Written and Stated.
Contract versions
Section titled “Contract versions”A generated agent’s managed region of its system prompt begins with a line naming the contract it was generated to:
=== SIFT MANAGED v5 — do not edit; regenerated with the filter ===Sift records the version when the agent is paired. Kindo cannot update an imported agent, so moving to a newer contract means regenerating, re-importing and re-pairing.
| Version | What changed |
|---|---|
| 5 (current) | A run of history carries delivery.history, and the agent is told to judge it as live and take no action outside the conversation. A live run is unchanged. A warm start sends to an agent at 5 without asking. |
| 4 | A run may carry a group: the group envelope, dispositions in the verdict, and how a group is judged and acted on. A single-event run is unchanged. |
| 3 | The managed region moved to the system prompt, and the verdict is recorded last, in a step of its own or the closing section of the only step. The envelope did not change. |
| 2 (oldest supported) | delivery replaced delivered_at, truncated was added, and the agent is told what a marked re-send asks of it. |
An agent generated to a version below 2 is out of date (agent.outdated): it may act
twice on an event sent twice. Grouping needs version 4. Being told an event is history
needs version 5; an agent at 2 to 4 keeps working, and the paired agent’s line on
Delivery says a newer agent shape is available.
named_inputs templates
Section titled “named_inputs templates”For a filter that sends to an existing agent, target.input_mapping maps each of the
agent’s declared input names, exactly, to a template. A template may contain only these
references:
| Reference | Renders as |
|---|---|
{{event.<path>}} | The event’s field at that dotted path, with [n] for a list index ({{event.fields.labels[0]}}). Text as is; numbers as written; true/false; null and missing fields as empty; objects and lists as JSON with sorted keys. |
{{sift.delivery}} | The delivery object above, as JSON. |
Anything else between double braces is refused when the filter is saved. A value
substituted in is never read again as a template, so an event whose text contains
{{sift.delivery}} is quoted, not expanded. For example:
{ "ticket_summary": "{{event.fields.summary}} (priority {{event.fields.priority}})", "ticket_key": "{{event.key}}", "delivery": "{{sift.delivery}}"}The input names must match the agent’s declared inputs exactly, every one mapped and none
extra. When Sift can read the agent’s inputs, it refuses a save that does not match. A named-input agent learns of a re-send only
if its author maps {{sift.delivery}}, best to an input of its own, since truncation cuts
the end of the longest input first.
{{sift.delivery}} never carries history, even when it is mapped: Sift cannot know that
the agent’s prompt says what the field asks, so it sends the bytes it always sent. A
named-input agent therefore cannot tell a warm start’s events, or a first poll’s
backlog, from live ones. If yours acts on what it judges (opens tickets, pages someone),
either skip the warm start, accept when it asks that the agent may act on history, or
move the filter to a generated agent at contract 5.