SkillKit.Webhook.Inbox behaviour (SkillKit v0.1.0)

Copy Markdown View Source

Behaviour for per-agent webhook delivery persistence and dispatch.

An inbox implementation owns three responsibilities:

  • persistence — storing verified inbound deliveries so an agent can read them back later via the webhook_inbox tool
  • dispatch — deciding when (immediately, debounced, throttled) and how (single pointer, coalesced batch) to notify the receiving agent via SkillKit.send_event/3
  • retention — bounding storage (LRU, TTL) so unbounded inbound traffic doesn't leak memory

The default implementation is SkillKit.Webhook.Inbox.Memory (ETS-backed, immediate dispatch). Hosts can swap in File, S3, or custom impls by passing {inbox: {MyInbox, opts}} to SkillKit.Webhook.Supervisor.start_link/1.

Entry shape

The Plug hands off a single map containing everything the inbox needs to both persist the delivery and, when it decides to emit, call SkillKit.send_event/3 on the agent:

%{
  agent: %SkillKit.AgentRef{},           # how to address the receiving agent
  prompt: String.t(),                     # user-authored webhook intent
  delivery: %{
    id: String.t(),
    webhook_id: String.t(),
    agent_name: String.t(),
    received_at: DateTime.t(),
    method: String.t(),
    headers: %{String.t() => String.t()},
    query: %{String.t() => String.t()},
    body: binary()
  }
}

Read semantics

read/4 resolves opts[:selector] against the delivery's structure, then optionally sub-slices:

  • when the resolved value is an array: offset + limit select a range
  • when the resolved value is multi-line text: line_start + line_end (exclusive) select a line range
  • when the resolved value is a string/binary: offset_bytes + limit_bytes select a byte range

The limit_bytes option is the final safety net: regardless of what slicing ran, the serialized result is capped at limit_bytes (default from inbox config) and truncated: true is set when clipped.

Opts that don't apply to the resolved value type are silently ignored.

Selector syntax

  • "" or "." — the whole delivery (rare; usually select a sub-tree)
  • "body" — the parsed body (JSON-decoded if applicable, else raw text)
  • "headers" — header map
  • "body.commits[0].message" — dot-walk + array indexing
  • "body.commits[].message" — array projection (map across array)
  • "headers.x-github-event" — header lookup

Summary

Callbacks

Recent deliveries for an agent, newest first.

Persist a delivery and decide when to emit it to the agent.

Read a slice of a delivery. See module doc for opts shape.

Structural sketch of one delivery (headers full, body key tree, no leaf values).

Functions

Composes the standard event text and send_event/3 opts for an entry without dispatching. Useful for custom dispatch modes (coalesced batches, etc.) and for unit-testing composition in isolation.

Composes and dispatches the event to the receiving agent via SkillKit.send_event/3. Returns :ok on success or {:error, :not_found} if the agent is not running.

Types

agent_name()

@type agent_name() :: String.t()

delivery()

@type delivery() :: %{
  id: delivery_id(),
  webhook_id: String.t(),
  agent_name: agent_name(),
  received_at: DateTime.t(),
  method: String.t(),
  headers: %{required(String.t()) => String.t()},
  query: %{required(String.t()) => String.t()},
  body: binary()
}

delivery_id()

@type delivery_id() :: String.t()

entry()

@type entry() :: %{
  agent: SkillKit.AgentRef.t(),
  prompt: String.t(),
  delivery: delivery()
}

inbox()

@type inbox() :: term()

read_result()

@type read_result() :: %{
  value: term(),
  bytes: non_neg_integer(),
  truncated: boolean(),
  total: non_neg_integer() | nil
}

summary()

@type summary() :: %{
  id: delivery_id(),
  webhook_id: String.t(),
  received_at: DateTime.t(),
  method: String.t(),
  body_bytes: non_neg_integer(),
  headers: %{required(String.t()) => String.t()},
  body: map()
}

Callbacks

delete(inbox, agent_name, delivery_id)

@callback delete(inbox(), agent_name(), delivery_id()) :: :ok

Evict a delivery.

list(inbox, agent_name, opts)

@callback list(inbox(), agent_name(), opts :: keyword()) :: {:ok, [summary()]}

Recent deliveries for an agent, newest first.

put(inbox, entry)

@callback put(inbox(), entry()) :: :ok

Persist a delivery and decide when to emit it to the agent.

The implementation is responsible for calling SkillKit.send_event/3 (or whatever :dispatch callback it is configured with) at the right time. The Plug returns 202 as soon as put/2 returns :ok; any delay in dispatch is the inbox's business, not the Plug's.

read(inbox, agent_name, delivery_id, opts)

@callback read(inbox(), agent_name(), delivery_id(), opts :: keyword()) ::
  {:ok, read_result()} | {:error, :not_found | :invalid_selector}

Read a slice of a delivery. See module doc for opts shape.

summary(inbox, agent_name, delivery_id)

@callback summary(inbox(), agent_name(), delivery_id()) ::
  {:ok, summary()} | {:error, :not_found}

Structural sketch of one delivery (headers full, body key tree, no leaf values).

Functions

compose(entry, inbox_ref)

@spec compose(
  entry(),
  {module(), term()}
) :: {String.t(), keyword()}

Composes the standard event text and send_event/3 opts for an entry without dispatching. Useful for custom dispatch modes (coalesced batches, etc.) and for unit-testing composition in isolation.

inbox_ref is {InboxModule, inbox_name} — threaded through so the injected webhook_inbox tool knows which inbox to call back into.

dispatch(entry, inbox_ref)

@spec dispatch(
  entry(),
  {module(), term()}
) :: :ok | {:error, :not_found}

Composes and dispatches the event to the receiving agent via SkillKit.send_event/3. Returns :ok on success or {:error, :not_found} if the agent is not running.

Impls call this from put/2 (or from a debounce/throttle timer) when they decide it's time to notify the agent, passing their own {InboxModule, inbox_name} so the agent's webhook_inbox tool calls back into the right impl.