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

# Meeting recap

> Minutes of the call, including what was shown on screen, with a Word document, in the StandIn Python SDK.

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.

```python theme={null}
from standin import Transcript

transcript = Transcript()
transcript.add(caller_name, "we should push the launch to March")
transcript.add("Assistant", "noted", role="assistant")
transcript.add_visual("Sara's shared screen: the Q3 revenue dashboard")
```

`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:

| Bound                        | Value | What it limits                                                                                                                                                                                                                                                    |
| ---------------------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `MAX_TRANSCRIPT_TURNS`       | 600   | Turns held at once.                                                                                                                                                                                                                                               |
| `MAX_TRANSCRIPT_VISUALS`     | 60    | Things shown. Far fewer than turns, because a screen changes slowly.                                                                                                                                                                                              |
| `MAX_TRANSCRIPT_ENTRY_CHARS` | 1000  | How long one entry may grow before the next turn from the same speaker starts a fresh one. Without it, an hour of one person talking coalesces into a single ever-growing entry the entry count can never trim.                                                   |
| `MAX_TRANSCRIPT_ENTRIES`     | 40    | Entries that reach the summarising model.                                                                                                                                                                                                                         |
| `MAX_TRANSCRIPT_CHARS`       | 12000 | Characters that reach it.                                                                                                                                                                                                                                         |
| `RECAP_MIN_TURNS`            | 4     | Below this there is no meeting to summarise, only a greeting. Advisory: it is exported for your plugin to check before it offers a recap at all. `post_minutes` itself only refuses an **empty** transcript, so a two-turn call still gets summarised if you ask. |

`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`](/python-sdk/chat#remembering-who-messaged-you) you construct once and share with your `ChatChannel`, so the chat lane fills it in as messages arrive:

```python theme={null}
from standin import ChatChannel, PersonalChats

chats = PersonalChats()
chat = ChatChannel(respond=on_message, chats=chats)
```

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.

```python theme={null}
from standin import resolve_minutes_target

target = resolve_minutes_target(
    thread_id=session.start.thread_id,
    human_count=participants,
    caller_aad_id=session.start.caller.aad_id,
    caller_chat=chats.for_caller(
        caller_aad_id=session.start.caller.aad_id, tenant_id=tenant
    ),
    session_tenant_id=session.start.tenant_id,
    config_tenant_id=configured_tenant,
)
```

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.

<Note>
  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.
</Note>

## Writing it up

```python theme={null}
from standin import post_minutes

result = await post_minutes(
    consultant.ask,
    transcript,
    target,
    deliver=post_to_chat,
    document_dir=Path("~/standin/minutes").expanduser(),
    subtitle=f"Call with {caller} - ~{minutes} min, {humans} human participants.",
)
await self.lane.say(result.spoken)
```

`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"`.

<Warning>
  `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`](/python-sdk/voice#saying-a-line-nobody-asked-for); 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`](/python-sdk/voice#playing-it-back) like any other line.
</Warning>

`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.

| Field       | What it holds                                                                             |
| ----------- | ----------------------------------------------------------------------------------------- |
| `spoken`    | One sentence for the agent to say. Always present, including on failure.                  |
| `minutes`   | The minutes themselves, so a caller can do something else with them when the post failed. |
| `document`  | Where the Word document was written, when one was.                                        |
| `delivered` | Whether the minutes actually reached the chat.                                            |
| `target`    | Which of the admissible conversations took them.                                          |

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.

<Note>
  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.
</Note>

## 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

<CardGroup cols={2}>
  <Card title="Chat" icon="comments" href="/python-sdk/chat">
    `PersonalChats`, and the lane the minutes are posted down.
  </Card>

  <Card title="Consulting" icon="hourglass" href="/python-sdk/consulting">
    The slow agent that writes the recap without stalling the call.
  </Card>
</CardGroup>
