Skip to content

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:

DeliveryAgentWhat it receives
event_blobOne Sift generated for the filterOne input, sift_event, holding the whole event in a JSON envelope.
named_inputsAn existing agent someone else wroteIts own declared inputs, each filled from a template.

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.

{
"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"
}
}

The sift object carries these keys and no others.

KeyTypeMeaning
filter_idstringThe filter that sent the event.
filter_versionintegerThe filter’s version when it was sent.
event_idstringSift’s id for the event. Use it to check whether you already acted on the event.
source_event_idstringThe event’s id in the source (the filter’s source.id_field).
represents_eventsintegerHow many events this one stands for, itself included. 1 for an ordinary event; more when Collapse repeats folded near-identical events into it.
truncatedobject or nullNull when the whole event fits. See Truncation.
deliveryobjectHow the event reached the agent.

delivery:

KeyTypeMeaning
attemptinteger1 for the first send, one more for each send after it.
sent_atstringWhen this send went out, ISO 8601 in UTC.
resendobject or nullNull unless an earlier send of this event may have started a run.
historyobjectOnly on a run of history, to an agent generated to contract 5 or later. Absent otherwise, not null. See History.

delivery.resend, when present:

KeyTypeMeaning
first_sent_atstringWhen the first send that may have reached the agent went out.
earlier_sendsintegerHow many earlier sends may have reached it. Sends that certainly did not (refused, never connected) are not counted.
reasonstringWhy, 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.

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:

KeyTypeMeaning
observed_atstring or nullWhen 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_atstringWhen 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.

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 keyMeaning
filter_id, filter_versionAs for a single event.
group_idSift’s id for the group.
events_in_groupHow many events the run carries, 2 to 50.
deliveryThe 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.

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 agent’s answer for each event is a disposition:

dispositionMeaningSift reads it as
actionableThe event is one the agent’s instructions say to act on.positive
benignIt is not.negative: the only verdict that teaches Sift what can be filtered
insufficient_dataThe 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.

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:

  1. Conversation metadata. A generated agent declares this schema for it (conversationMetadataSchema):

    FieldTypeRequiredMeaning
    dispositionactionable, benign, insufficient_datayesThe verdict. In a group, the most severe of the group’s: actionable, then insufficient_data, then benign.
    dispositionsobject, fields e01 to e50, each a dispositionnoIn a group only: the verdict on each event, by slot.
    rationalestring, up to 4,096 charactersnoOne or two sentences on why.
    sift_unconfiguredbooleannotrue only 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.

  2. The final message, on a line of its own:

    disposition: benign

    and in a group, each slot on its own line as well:

    disposition: actionable
    e01: actionable
    e02: benign

    A JSON object with a disposition field (or a dispositions object), 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.

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.

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.

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.

VersionWhat 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.
4A 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.
3The 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.

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:

ReferenceRenders 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.