Skip to content

Filter app events by name glob and bound payloads on every surface #94

Description

@V3RON

Lands after #93, which leaves the retention buffer, appduct_events, appduct_wait_for_event and appduct events carrying app events only. Everything below assumes that: an event is { name, payload, ts, seq, sessionId, alias } and kinds no longer exists on any agent- or user-facing surface. The RPC's events.subscribe keeps kinds for the daemon's own consumers, such as the wait for session_claimed behind appduct_wait_for_session.

Why

An app that posts events often makes the current surface expensive for an agent:

  • appduct_events filters by kind only. To find checkout_completed among two hundred app events, the agent drains all of them into its context and scans. One call can return the whole 256-event buffer, each payload up to the 256 KiB wire limit, with no cap.
  • appduct_wait_for_event targets one exact name and a shallow payload match; the client SDK has a predicate function instead. Two filtering models for one concept.
  • A since cursor that fell off the buffer returns whatever is left with no signal that anything was evicted, and a limit-truncated page gives no hint whether to page again.
  • appduct events has no filter at all, although events.subscribe already accepts one per connection (packages/appduct/src/daemon/daemon.ts:519).
  • The shipped skill never mentions appduct_events or appduct_wait_for_event, so agents do not know the tools exist or how to loop on the cursor.

Expected outcome

One filter, by name, applied in the daemon on both the drain (events.since) and the live subscription (events.subscribe), plus a payload cap and two counters, exposed the same way on every surface.

name, a whole-name, case-sensitive pattern where * matches any run of characters and a pattern without * is an exact name. cart.* matches cart.item_added; *_failed matches checkout_failed.

  • appduct_events({ selector?, since?, name?, limit?, payloadMaxBytes? }), limit defaulting to 50 like appduct_list_tools.
  • appduct_wait_for_event({ selector?, name, since?, timeoutMs?, payloadMaxBytes? }). name stays required; * waits for the next app event of any name. The match object is removed.
  • appduct events tail [selector] [--name <glob>] [--payload-max-bytes <n>] and appduct events since [selector] <cursor> [--name <glob>] [--payload-max-bytes <n>], both flags on both verbs.
  • appduct/client: events({ name?, since?, limit?, payloadMaxBytes? }) and waitForEvent(name, { since?, timeoutMs?, payloadMaxBytes? }). The match predicate is removed.

payloadMaxBytes, per event. A payload whose JSON exceeds it is replaced by payloadPreview (the first payloadMaxBytes characters of that JSON) with truncated: true and payloadBytes: n on the event; otherwise payload is untouched. It is a param on both events.since and events.subscribe, so live events (the wait tool's live phase, appduct events without --since) are capped by the daemon too. The two MCP tools default it to 4096; the RPC params, the CLI flag and the SDK have no default. The full payload comes back with since: seq - 1, limit: 1 and a larger cap.

dropped: n on every events.since result: the number of events between since and the oldest retained one, 0 when nothing was evicted. appduct_wait_for_event reports it the same way when its drain phase crossed a gap.

remaining: n on every events.since result: matching events after the returned page, so an agent pages only while it is above 0. The trailing cursor line that events since prints becomes { cursor, dropped, remaining } under --json and cursor: 12, dropped: 0, remaining: 40 otherwise.

The shipped skill teaches two patterns: after a call that triggers async work, drain with appduct_events and a name glob, keep the cursor, page while remaining > 0, treat dropped > 0 as a gap; for an expected outcome, appduct_wait_for_event with since and a long timeoutMs, which Claude Code moves to a background task after two minutes and reports as a task notification. The CLI reference table gains the new flags.

Constraints and non-goals

  • No change to the app-to-daemon wire protocol. Event names stay unconstrained on the wire; a literal * in a name cannot be matched exactly, and the writing-tools guidance recommends [a-z0-9_.] names.
  • The RPC change is additive apart from removing match. events.since loses kinds in Log Appduct's own event kinds to a file instead of showing them to agents #93; events.subscribe keeps it for internal consumers.
  • Non-goal: payload predicates of any kind, on any surface. The SDK's predicate goes; a test that needs one loops on waitForEvent with since or filters events() itself.
  • Non-goal: waiting on several names in one call beyond what a glob expresses.
  • Non-goal: an event catalog (apps declaring the events they post). Separate design.

How we know it is done

  1. events.since with name: "cart.*" returns only events whose name matches; name: "checkout_completed" never matches checkout_completed_v2; matching is case-sensitive.
  2. events.subscribe with name delivers only matching live events on that connection.
  3. appduct_events with no limit returns at most 50 events and a cursor that pages forward.
  4. An event whose payload JSON is longer than payloadMaxBytes comes back with payloadPreview, truncated: true and payloadBytes, and no payload; a smaller one comes back unchanged. appduct_events and appduct_wait_for_event apply 4096 by default; appduct events tail --json, appduct events since --json and app.events() do not truncate unless told to.
  5. After more than eventBufferSize app events, events.since with the original cursor reports dropped equal to the number evicted, and dropped: 0 otherwise.
  6. remaining equals the number of matching events after the returned page and is 0 on the last page.
  7. appduct_wait_for_event rejects match as an unknown parameter and resolves on name: "*" for the next app event.
  8. appduct events tail --name '*_failed' --json and appduct events since 0 --name '*_failed' --json print only matching NDJSON lines, and the events since cursor line carries dropped and remaining.
  9. app.waitForEvent("cart.*") resolves on cart.item_added; WaitForEventOptions has no match.
  10. The shipped skill describes the drain loop and the background wait, and the CLI reference lists the new flags under events tail and events since.

Alternatives considered

  • A match object with dot-path keys over name and payload: rejected, one filter by name is enough and easier to teach.
  • Truncating in the MCP server only: rejected, the daemon param serves CLI, SDK and MCP from one implementation.
  • oldestSeq instead of dropped: rejected, agents would have to do the subtraction.

Slices

Work these in order (design):

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:mcpMCP server and built-in toolsstatus:readySpec and fix direction are clear; an agent can pick it uptype:featureSomething Appduct should do that it does not today

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions