Skip to content

feat(triggers): a meet trigger whose semantics are a meeting - #191

Merged
RatulMaharaj merged 2 commits into
mainfrom
feat/190-meet-trigger
Aug 1, 2026
Merged

feat(triggers): a meet trigger whose semantics are a meeting#191
RatulMaharaj merged 2 commits into
mainfrom
feat/190-meet-trigger

Conversation

@RatulMaharaj

Copy link
Copy Markdown
Member

Closes the trigger half of #190. Now based on main, since the previous six merged.

Why not just use tty

An agent joins a meeting over tty today, and docs/tty.mdx states its contract:

Each connection is a conversation.

That is right for a terminal, where a new window should be a fresh start, and backwards for a meeting. The meeting is the conversation; connections come and go inside it while a laptop sleeps, a bridge reconnects, or a second client attaches.

The transport is not the problem, so this keeps it. WebSocket is right here: bidirectional, long-lived, and the frames are already JSON. Streaming HTTP would be worse, since SSE is one-directional and input would need a second connection with its own lifetime. Media stays the bridge's job. What changes is what a connection means.

What is different from tty

join is required and carries the meeting id. The key is meet:<meeting_id>. A connection that never names a meeting is refused rather than handed an invented one, so #189's failure mode cannot occur here by construction rather than by warning.

participant records who came and went without spending a turn. A busy room would otherwise start a run per arrival.

end is distinct from the socket closing, and it is the frame that earns the trigger. Today a closed socket is ambiguous between a dropped connection and a finished meeting, and the agent cannot tell which, so it can never reliably write the record. end says which happened while the conversation is still in context.

summarize_on_end is opt-in:

triggers:
  - type: meet
    token_env: MEET_TOKEN
    summarize_on_end: |
      The meeting is over. Write the record: what was decided,
      what is still open, and who owns what.

Set it and end spends one more turn; the reply comes back as {type: "summary", text} and lands in the transcript, which is the record that outlives the meeting and the thing #173 needs. Leave it out and an ending costs no model call, because a summary per meeting is not something every agent wants.

What stays the same

tty is untouched and remains a terminal. The genuinely shared pieces (image validation, the constant-time token compare) moved to ws_session.ts with no behaviour change; the tty tests still pass unmodified.

Testing

515 pass, deno task ok clean. Eight new, each pinning a way this differs from a terminal:

  • nothing runs before join, and an empty meeting_id is refused
  • a reconnect to the same meeting resumes the same conversation, asserted against the stored transcript rather than a frame
  • two meetings do not share a conversation
  • end writes the record, and the summary is in the transcript rather than only on the wire
  • without summarize_on_end, an ending adds no messages and sends no summary
  • participant frames add no turns, and a malformed one errors
  • unauthorized upgrades refused before any frame
  • triggersFromConfig builds it and names a missing token env var

What is not done

report_to. An agent can schedule follow-up work during a meeting; the schedule survives restarts and the work runs. When it fires after the meeting there is no open socket, deliver returns false, and AgentService.#deliver falls back to a logInfo truncated at 200 characters. Routing that to another trigger is a service-level concern rather than a trigger one, so it is still open on #190.

A meeting that ends without an end frame. If a bridge cannot always tell that a meeting finished, the record never gets written. Documented rather than papered over, and it is the first question I asked the bridge side to answer.

The bridge side is being written in parallel against this frame vocabulary.

RatulMaharaj and others added 2 commits August 1, 2026 21:43
An agent joins a meeting over tty today. tty is documented as "each
connection is a conversation", which is right for a terminal and the
opposite of what a meeting needs: the meeting is the conversation, and
connections come and go inside it while a laptop sleeps or a bridge
reconnects.

The transport is not the problem, so meet keeps it. WebSocket is right
for this and the frame vocabulary is already JSON. What changes is what a
connection means.

The meeting id arrives in a required join frame rather than an optional
query parameter, and the key is meet:<meeting_id>. A connection that
never names a meeting is refused rather than handed an invented id whose
history nothing could reach again, which is the tty failure #189 is
about; meet sidesteps it by construction rather than warning about it.

Two frames a terminal has no concept of: participant, which is recorded
without spending a turn because a busy room would otherwise start a run
per arrival, and end.

end is the one that earns the trigger. A closed socket is ambiguous
between a dropped connection and a finished meeting, and the agent cannot
tell which. end says so while the conversation is still in context, and
with summarize_on_end set the agent spends one more turn writing the
record. That turn lands in the transcript, so the record outlives the
meeting. Without the setting an ending costs no model call, because a
summary per meeting is not something every agent wants.

tty is untouched and stays a terminal. The shared transport pieces (image
validation, the constant-time token compare) moved to ws_session.ts with
no behaviour change.

Refs #190. The remaining half is report_to: a scheduled result that fires
after the meeting still falls back to a log line, because delivery to
another trigger is a service-level concern rather than a trigger one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPftW3F8JTuivRhFmoY1wC
Caught by the bridge side while designing against this: a bridge can race
itself into sending end twice, most likely a reconnect delivering an end
that was already in flight. The trigger recorded the meeting as ended and
then ran the summarize turn regardless, so one meeting could pay for two
model calls. That is the kind of duplicate that only shows up on the bill.

A second end on a meeting already ended closes the socket and returns.

Also pins the other half of that conversation: the bridge fires end and
goes rather than waiting for the summary, so the summary frame lands on a
closed socket. That is fine and now has a test saying so — send() already
no-ops on a closed socket, and the run still completes and still writes to
the transcript. The transcript is the record; the frame is a convenience
for a client that happens to linger.

It does make report_to the real delivery answer rather than a nicety,
which is worth knowing before this ships.

Refs #190

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CPftW3F8JTuivRhFmoY1wC
@RatulMaharaj
RatulMaharaj merged commit f872d43 into main Aug 1, 2026
2 checks passed
@RatulMaharaj
RatulMaharaj deleted the feat/190-meet-trigger branch August 1, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant