Skip to main content
A recap is the one thing people ask an agent for that it cannot do while the call is happening. It needs the whole conversation, so it happens at the end, and by then the caller has usually gone. What makes this one worth having is the second track. Every transcript-first recap tool is blind to the screen share. Your agent was on the call and could see it.

Keeping the record

Feed the transcript as the call runs, from whichever callbacks your plugin already has.
role is "caller" unless you say otherwise, and it is what tells the two sides apart in the document. A consecutive turn from the same speaker and the same role is merged into the one before it, because a live transcript arrives as fragments and half-sentences fed to a model as separate turns make the minutes read like a stutter. Merging never crosses speakers: filing one person’s words under another’s name is worse than no attribution, because it is confidently wrong. Both tracks are bounded, because a two-hour meeting sits in the memory of a process that is also carrying live audio. What survives is the tail, since the end of a meeting is what minutes are mostly about. Here are the numbers, so “bounded” is something you can check: MAX_TRANSCRIPT_ENTRIES, MAX_TRANSCRIPT_ENTRY_CHARS and RECAP_MIN_TURNS are on the standin barrel. The three hard holding caps are from standin.minutes import MAX_TRANSCRIPT_TURNS, MAX_TRANSCRIPT_VISUALS, MAX_TRANSCRIPT_CHARS. Consecutive visual repeats are collapsed. The vision lane describes whatever is on screen each time it is asked, and a slide nobody changed would otherwise fill the record with the same line.

Where the minutes go

Decided once, before anything is written, and then passed to every later step. resolve_minutes_target returns a DeliveryTarget: a kind of "thread" or "caller-dm", a conversation_id and a tenant_id, and nothing else. caller_chat comes from a PersonalChats you construct once and share with your ChatChannel, so the chat lane fills it in as messages arrive:
Without one, a one-to-one call resolves to no target and gets no minutes, which is the correct outcome rather than a bug: there is no conversation that can be asserted as that caller’s. Both lanes have to run in one process for this to work.
This is the highest-consequence rule in the feature. A send with no pinned recipient falls back to whatever conversation the sending code last saw, and a customer’s meeting minutes are the most sensitive thing this product produces. So the target is one immutable value, and no step downstream is allowed to work out a recipient of its own. Three details are load-bearing, and each one is a real failure that happened: A meeting thread counts even when the participant count says one. The count only arrives on topologies that send a participants frame, so on some deployments it stays pinned at 1. A count-only test delivered the minutes of a group call into one attendee’s private chat. The caller’s own chat is admitted by scope, never by the shape of its id. A personal chat and a group thread are told apart by what the message said its scope was. Testing the id prefix inverts it, admitting a team channel and rejecting every real private chat. The tenant is the session’s, then the configured one, then the remembered sender’s. Never the caller’s own. A guest’s tenant describes the organisation they came from, which is not the one this worker is bound to, and it is the one plausible-looking source that is actively wrong. A call that identifies nobody gets no target, and therefore no minutes. That is the correct outcome: there is no conversation that can be asserted as theirs. Pass a list when more than one place is admissible, best first. The walk advances only on a 404, because that is the only answer that proves nothing was delivered, so it is the only one where trying again cannot duplicate. A 401 or a 5xx would fail identically elsewhere.
The SDK’s own chat lane writes to a socket and gets no status back, so it can report that a message was sent and never that it was refused. A poster built on it never advances the walk, which is the safe behaviour rather than a bug. Supply a poster that surfaces a status if you want the fallback to fire.

Writing it up

document_dir decides whether a Word document is written at all: leave it out and none is. subtitle is the line under the document’s title, and assistant_label and caller_label name the two sides in the attributed transcript, defaulting to "Assistant" and "Caller".
result.spoken goes back through whatever makes your provider talk. There is no session.say: CallSession carries send_audio and nothing that synthesizes. lane.say above is a VoiceLane; a realtime plugin hands the sentence back as the tool result, which is what the model then reads out, and a handler assembling its own pipeline synthesizes it and plays it through PacedPlayback like any other line.
post_minutes never raises. It normally runs during teardown, where an exception takes the whole teardown with it, so every failure comes back as a sentence instead. It degrades a step at a time: a document that cannot be built sends text only, a send that fails is logged, and the spoken sentence is always there. Two outcomes look similar and are kept apart on purpose. A call with nothing said is told it was too short. A call with plenty said but no conversation to post into is told exactly that. Conflating them tells people their conversation did not count when it did. RecapResult is what comes back. Your poster may answer with a plain True or with a PostOutcome, which is ok plus the status behind it. Both are read correctly, and from standin.minutes import PostOutcome is where it lives, because it is not on the standin barrel. Do not hand back an object of your own and expect it to be truth-tested: every object is truthy, and a post StandIn rejected would report as delivered. Only a real bool is read as one.

The document

write_minutes_docx emits a Word-openable .docx with no dependencies. A document format library would be a dependency every install pays for so that the small fraction who ask for minutes get a file, which is the wrong trade for an SDK. Give it sections from parse_minutes_sections, a list of MinutesSection, and it renders real headings instead of a flat wall of lines. A model asked for ### Key points will write ## Key points or **Key points:** depending on the model and the day, so all of those are accepted; a section that ends up with nothing in it is left out entirely rather than printed as a heading over white space. Give it transcript and it appends an attributed transcript, which is the half a transcript-only tool cannot produce: the call gave you the real speaker per utterance. A turn that already carries its own Name: prefix is written through untouched, because relabelling it as a generic caller destroys the attribution and double-prefixing reads as a transcription fault. has_speaker_prefix is that test, exported so your own writer can make the same decision.
Delivery to the chat is text. The document is written to disk beside it, for whoever keeps the record. A meeting chat cannot be sent a file by a bot the way a person can, so a document promised into the chat would be a promise that quietly fails. Say so in the message rather than leaving it as a silent difference: DOCUMENT_NOT_ATTACHED is that sentence.

The prompt

minutes_prompt asks for Key Points, Decisions, Action Items and, when anything was shown, Presented. The instruction not to infer what was on screen is the load-bearing one. A model handed “Sara shared a dashboard” will happily invent the numbers on it, and minutes that invent numbers are worse than minutes with a gap. is_summary_request recognises somebody asking for the write-up in conversation. It needs both halves: “summarise” alone is asked about a document, an email, or a page the agent is looking at. MINUTES_TOOL is the tool declaration, ready to register on CallTools. It is named post_meeting_minutes, not after the function that does the work, because a model handed two things with one name cannot tell which it is calling. It is not built in: whether there is anywhere to post depends on the call, and resolve_minutes_target is what answers that. Answer the model the moment it calls, then run the recap. A tool call with no result stalls the turn, and the recap is a full model run plus a document write plus a send, so the caller sits in silence wondering whether anything is happening.

Next

Chat

PersonalChats, and the lane the minutes are posted down.

Consulting

The slow agent that writes the recap without stalling the call.