> ## Documentation Index
> Fetch the complete documentation index at: https://sdk.ekanos.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Agent events

> Let teams start their managed agents from things that happen in your integration, and let those agents answer where it happened.

Teams build **managed agents** in Ekanos: agents with their own instructions, tools and approval policy. An agent starts from a trigger. Declare **agent events** and every team that connects your integration can pick one as a trigger: "when a payment fails, start the billing agent". Declare an **agent channel** too and the agent answers where the event happened, for example as a note on the payment.

Your integration raises events; Ekanos does the rest. It validates each event, records it once, matches it against every team's triggers, starts the agents, gives each run a reply tool bound to the right place, and runs every reply through its write gate (approval or pre-authorization, idempotency, audit). Your code never starts an agent and never chooses where a reply goes.

## Declare an event

```ts theme={null}
import { defineAgentEvent } from '@ekanos/sdk/integration';
import { z } from 'zod';

export const paymentFailedEvent = defineAgentEvent({
  // "<your slug>.<event>". Stored on teams' triggers: never rename it once shipped.
  id: 'acme-payments.payment_failed',
  label: 'A payment failed',
  description: 'A customer payment moved to failed.',

  // Every raised payload is validated against it before anything sees it.
  payloadSchema: z.object({
    paymentId: z.string(),
    amountCents: z.number().int(),
    method: z.enum(['card', 'ach', 'wire']),
  }),

  // The form a team fills in when it picks this trigger. Optional.
  filter: {
    fields: [
      { key: 'methods', label: 'Payment methods', type: 'text-list' },
      {
        key: 'largeOnly',
        label: 'Only payments of $1,000 or more',
        type: 'boolean',
      },
    ],
  },

  // Whether a trigger's filter wants this event. Default: always.
  matches(filter, payment) {
    const methods = Array.isArray(filter.methods) ? filter.methods : [];
    if (methods.length > 0 && !methods.includes(payment.method)) return false;
    return !filter.largeOnly || payment.amountCents >= 100_000;
  },

  // Fixed, trusted framing for the run. Never built from the payload.
  instructions:
    'An Acme payment failed. Work out why and post one note on the payment with acme_payments_reply.',

  // Where a run answers. Needed for the channel below.
  conversation: (payment) => ({
    key: `payment:${payment.paymentId}`,
    target: { paymentId: payment.paymentId },
  }),
});
```

`defineAgentEvent` only types the functions by the payload schema. `defineIntegration` validates the declaration with the rest:

```ts theme={null}
export const integration = defineIntegration({
  // ...
  agentEvents: [paymentFailedEvent],
});
```

| Field | What it's for |
| - | - |
| `id` | `<slug>.<event>`, lowercase, e.g. `acme-payments.payment_failed`. Must start with your slug. |
| `payloadSchema` | A zod schema. A raised payload that fails it is dropped. |
| `filter.fields` | `text`, `text-list`, `boolean` or `select` fields. The builder renders them and stores the answers as the trigger's filter. |
| `matches(filter, payload)` | Pure. Return `false` to skip this trigger. |
| `instructions` | The run's trusted framing. The payload reaches the model only as untrusted data, never as instructions. |
| `conversation(payload)` | `{ key, target }`: `key` groups follow-ups, `target` is your own address for the channel. Return `null` when this event has nowhere to answer. |
| `isOwnActivity(payload)` | Return `true` for activity your channel caused (the agent's own note), so it never starts the agent again. |
| `routeKeys` | Pure: the event's value per routing key, to route a Source connection's event. See [Routing](https://sdk.ekanos.dev/sdk/surfaces/routing.md). |
| `examplePayload` | Plain JSON. Shown in the harness. |

## Raise it from a webhook

Return the events a delivery raises in the webhook result:

```ts theme={null}
async handler(ctx, event) {
  const { payment, previousStatus } = event.payload as PaymentUpdatedPayload;
  // ... your usual processing ...

  return {
    status: 'processed',
    events:
      payment.status === 'failed'
        ? [
            {
              id: 'acme-payments.payment_failed',
              payload: { paymentId: payment.id, amountCents: payment.amountCents, method: payment.method },
              // Stable for this occurrence. A redelivered webhook raising the
              // same key starts nothing twice.
              dedupeKey: `${payment.id}:failed`,
            },
          ]
        : undefined,
  };
}
```

* The host records each event before acknowledging the delivery. Agents start after the response, so a slow agent never delays your webhook.
* `dedupeKey` is scoped to the account. Use the vendor's delivery or event id, or whatever makes this occurrence unique (issue id + label id).
* An event your integration doesn't declare, or whose payload fails its schema, is logged and dropped; the delivery is still acknowledged. If the host can't record an event, the delivery fails with a retryable error, and the dedupe key makes the retry safe.
* At most 20 events per delivery.

## Declare a channel

A channel is how a run answers. The host gives the run a reply tool named by you, bound to the `target` your event's `conversation()` returned. The model writes the reply; it never picks the target.

```ts theme={null}
import type { PartnerAgentChannelDeclaration } from '@ekanos/sdk/integration';

export const agentChannel: PartnerAgentChannelDeclaration<MyStorage> = {
  replyTool: {
    // Must start with your slug, with "-" as "_".
    name: 'acme_payments_reply',
    description: 'Post a note on the Acme payment that started this run.',
  },

  // Runs only after the host's write gate. Make the vendor call; return the outcome.
  // `run` ({ runId, agentRunId }) is the same for every call one run makes.
  async deliver(ctx, target, reply, run) {
    const apiKey = await ctx.secrets.get('api_key');
    const response = await ctx.fetch(
      `https://api.acme-payments.example/v1/payments/${target.paymentId}/notes`,
      {
        method: 'POST',
        headers: {
          authorization: `Bearer ${apiKey}`,
          'content-type': 'application/json',
        },
        body: JSON.stringify({ body: reply.body, internal: reply.interim }),
      },
    );
    return response.ok
      ? { ok: true }
      : { ok: false, error: `Acme answered ${response.status}.` };
  },

  // Optional, best-effort: fixed text only (never model output). A hook that throws is ignored.
  hooks: {
    async runStarted(ctx, target, run) {
      /* e.g. react 👀, with an id derived from run.runId */
    },
    async runEnded(ctx, target, outcome, run) {
      /* e.g. swap 👀 for ✅ */
    },
    async handoff(ctx, target, handoff, run) {
      /* e.g. an internal note with handoff.reason, and assign a human group */
    },
  },
};
```

`deliver` and the hooks get the same `ctx` as your other handlers, built for the account the run belongs to, with your declared `egress` enforced. `reply.interim` is `true` for a progress note; the answer comes in its own reply. If a run completes without answering, the host posts its final output through `deliver` for you.

### Hand-off to a person

When a reply needs approval and a reviewer rejects the draft, the host calls `hooks.handoff(ctx, target, { reason }, run)` so the conversation goes back to a human the vendor's way: for a help desk, an internal note with the reason and the ticket assigned to a human group. `reason` is the reviewer's own note, or text the host composes from fixed fields, never model prose, so post it internally and never to the customer. The hook is not called when an approval merely expires. Like the other hooks it is best-effort.

### Routing and the unrouted hook

If a Source connects your integration once for all its teams, its events are routed to a team by a key you declare, and `hooks.unrouted` posts a fixed note on an event no team is mapped to yet. See [Routing and Source connections](https://sdk.ekanos.dev/sdk/surfaces/routing.md).

A channel needs at least one event with a `conversation`. Events without a channel still work: those runs answer in Ekanos only.

## Try it in the harness

The **Triggers** page lists your agent events and channel. Deliver a webhook and the result shows every event it raised, checked the way the host checks it: declared, payload valid, not your own activity, and where a run it starts would answer. The harness starts no agents.

```ts theme={null}
import { invokeWebhook } from '@ekanos/sdk/testing';

const { result } = await invokeWebhook(integration, 'payment-updated', payload);
expect(result.events?.[0]?.id).toBe('acme-payments.payment_failed');
```

The full example is `packages/integrations-examples/acme-payments` (`src/events/agent.ts`).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.