> For the complete documentation index, see [llms.txt](/llms.txt).
> A full single-fetch corpus is available at [llms-full.txt](/llms-full.txt).
---
title: Build a standup digest for Claude/Codex
description: Read Linear, GitHub, Slack and Google Meet notes on every run, count in code, and let a model write only the judgment. Publish it as an MCP server and ask for the digest in Claude or Codex.
tags: ["MCP", "Agents", "Linear", "GitHub", "Slack", "Google Drive", "Claude", "Codex"]
date: 2026-10-22
last_verified: 2026-10-22
audience: both
---

Ask Claude or Codex for the standup digest. You get one brief, built from Linear, GitHub, Slack and yesterday's Google Meet notes. It has a scoreboard per person, the tickets that need attention, a check of what each person said they'd do, the places where the sources disagree, and three questions for the call.



[Video](/videos/cookbooks/standup-overview-light.mp4): In Claude, a user asks for the standup digest for 2026-10-22. Claude calls the standup_digest tool, and the AGNT5 run card ticks through 19 steps, from linear_list_issues and slack_read_channel to writer_agent and check_brief. The brief appears with a scoreboard, a needs-attention list, a plan check for each person, a sources-disagree line and questions for the call.

_One question in Claude, one durable run on AGNT5. The team, tickets and numbers are sample data shaped like a real run._



## Why it's built this way

Before standup, someone clicks through Linear, GitHub and Slack, and rereads yesterday's notes. Updates come out vague ("still working on alerts"), and nobody checks whether what people planned yesterday actually happened.

Handing that to an assistant with MCP tools for each app looks like the fix. In daily use, the model decides which sources to read and how, and it gets that wrong in ways nobody sees. Our first version gave one agent per source its app's MCP tools. The Linear agent passed made-up filters, called the same comments tool five times on one ticket, ran out of steps and returned nothing. The brief reported no Linear updates, on a day the team had closed dozens of tickets. The GitHub agent took 19 calls to do what one search does. [Others report the same pattern](https://community.crewai.com/t/mcp-server-tool-returns-accurate-data-but-final-report-contains-hallucinated-metrics/7110). The tool returns the right data, and the final report still has made-up numbers.

So this recipe gives each part of the job to the thing that does it reliably.

| What goes wrong | What this recipe does |
|---|---|
| The model skips a source, or reads nothing and reports "nothing new" | The workflow makes fixed calls to every source. A source can fail, but it can't be skipped, and a failure is named in the brief's header. |
| Counts and dates come out wrong | Code computes the scoreboard, including done, started, open, stale and what needs attention. |
| The model invents a status or a number | Every plan check, mismatch and question must cite a ticket, PR or Slack message the sources returned. `check_brief` drops anything else. |
| You can't see what was actually read | Every MCP call is its own step, listed in Claude's run card and in Studio with its arguments and result. |
| Yesterday's plan never gets checked | An agent reads the call's Gemini notes and transcript from Drive and pulls out each person's plan, and the brief checks it against Linear, GitHub and Slack. |

A model still does the parts that need judgment, which are finding the right meeting notes and writing the summary, plan check and questions for the call.

## How it works



[Video](/videos/cookbooks/standup-architecture-light.mp4): Architecture. Claude calls standup_digest over MCP. The workflow reads Linear, GitHub and Slack through their MCP servers as parallel steps, while the Meet reader agent reads the notes from Google Drive. The scoreboard is computed in code, the writer agent writes the judgment, check_brief drops anything without a real ref, and the rendered brief goes back to Claude. A failed source shows as a cross in the header.

_Every source is read on every run. Code counts, and the model only judges._



| Step | What it does |
|---|---|
| `resolve_window` | The day and window in UTC, from the previous workday's start to the end of the day |
| `linear_list_users` | The members of `LINEAR_TEAM`, who are the people in the brief |
| `linear_list_issues` ×5 | Issues updated in the window, plus every open issue (triage, backlog, unstarted, started) |
| `github_search_pull_requests` | PRs updated in the window, with the tickets they mention |
| `github_user_names` | PR authors' profile names, to match them to the Linear team |
| `slack_read_channel` | Messages in the team's channels in the window |
| `meet_reader` agent | Finds the last standup's Gemini notes in Drive and extracts each person's plan and blockers |
| `build_scoreboard` | Per-person counts, ticket ages, stale tickets and what needs attention (code, no model) |
| `writer_agent` | Writes the summary, movement, plan check, mismatches and questions, with refs |
| `parse_brief` → `check_brief` | Validates the writer's JSON, then drops findings whose refs no source returned |

### The workflow

The four sources are read in parallel. Three are fixed MCP calls, and the Meet notes need an agent, because finding the right document and pulling out each person's plan takes judgment.

```python title="workflows.py (excerpt)"
@workflow
async def standup_digest(ctx: WorkflowContext, date: str = "") -> dict:
    # 1. The day and window (UTC), and who's on the team
    window = await ctx.step(resolve_window, date=date)
    team = await read_roster(ctx)

    # 2. Read every source in parallel, so none can be silently skipped
    linear, github, slack, meet = await ctx.parallel(
        read_linear(ctx, team, window),
        read_github(ctx, team, window),
        read_slack(ctx, team, window),
        read_meet(ctx, team, window),
    )

    # 3. Code does the counting
    board = await ctx.step(build_scoreboard, issues=linear["issues"], window=window)

    # 4. The model only judges, and every claim must cite a ref the sources returned
    answer = await write_brief(ctx, board, github, slack, meet)
    brief = await ctx.step(parse_brief, **answer)
    brief = await ctx.step(check_brief, brief=brief, known=refs(linear, github, slack))

    # 5. Code renders the same layout every day
    return render(window, board, brief)
```

### One step per MCP call

Each MCP call is its own `@function`, named after the tool it calls. That makes every call a checkpoint, and every call shows up on its own in Claude's run card and in Studio, with its arguments and result.

```python title="fetch.py (excerpt)"
FIELDS = ["id", "title", "status", "statusType", "priority", "assignee", "url",
          "createdAt", "updatedAt", "startedAt", "completedAt", "canceledAt"]
OPEN_STATES = ["triage", "backlog", "unstarted", "started"]


@function
async def linear_list_issues(ctx: FunctionContext, team: str, filters: dict, cursor: str = "") -> dict:
    """Linear MCP `list_issues`: one page of the team's issues."""
    args = {"team": team, **filters, "fields": FIELDS, "limit": 250}
    if cursor:
        args["cursor"] = cursor
    return await mcp_call("linear", "list_issues", args)


async def read_linear(ctx: WorkflowContext, team: str, since: str) -> list[dict]:
    """Every issue updated since `since`, plus every open issue."""
    queries = {"updated": {"updatedAt": since}}
    queries.update({state: {"state": state} for state in OPEN_STATES})

    async def all_pages(label: str, filters: dict) -> list[dict]:
        issues, cursor = [], ""
        for page in range(5):
            result = await ctx.step(linear_list_issues, team=team, filters=filters,
                                    cursor=cursor, key=f"linear:{label}:{page}")
            issues += result["issues"]
            cursor = result.get("cursor", "")
            if not result.get("hasNextPage"):
                break
        return issues

    pages = await ctx.parallel(*(all_pages(label, f) for label, f in queries.items()))
    return list({issue["id"]: issue for issue in sum(pages, [])}.values())
```

The five Linear queries run in parallel, so each step gets a stable `key=` (`linear:started:0`). On a replay, AGNT5 matches each step to its recorded result by that key instead of by the order the steps happened to start in.

A step returns its error instead of raising, so one broken source doesn't fail the run. The brief's header names the failed source and its error, such as `SLACK_MCP_TOKEN is not set`, and the other three sources still render.

### Agents where judgment is needed

`meet_reader` gets two Drive tools, `search_drive_files` and `read_drive_file`, and returns each person's plan, done and blocked items as JSON. `writer_agent` has no tools. It reasons over the compacted input and returns JSON matching the `Brief` schema.

```python title="agents.py (excerpt)"
instructions = f"""{WRITER_PROMPT}

Return ONLY a JSON object matching this schema, no prose:
{json.dumps(Brief.model_json_schema())}"""

writer = Agent(
    name="writer_agent",
    model="openai/gpt-6-luna",
    instructions=instructions,
    max_iterations=2,
)
result = await writer.run(json.dumps(payload), context=ctx)
```

The prompt tells the writer what each part is for, and sets the rules that keep it honest.

```text title="agents.py (WRITER_PROMPT, excerpt)"
- plan_check: one entry per plan item from the last standup. verdict "done" only if its ticket is in the
  person's done list (…); "in_progress" if there is activity but its ticket is still open, even when a
  PR merged; "no_evidence" if nothing in the input shows it.
- mismatches: places where sources disagree, e.g. a person says only testing is left but tickets are
  still In Progress; a blocker said in Slack or the meeting with no Blocked ticket. Name both sides.

Rules:
- refs must be copied exactly from the input: ticket ids, PR urls, or slack:<ts>.
- Use only the input. Mark anything inferred with "(inferred)".
```

`parse_brief` validates the answer against `Brief`. An invalid answer becomes an empty brief that says why, so the scoreboard and ticket lists still render. `check_brief` then drops any finding whose refs aren't in what the sources returned.

### Code does the counting

`build_scoreboard` works from the Linear issues alone.

- **Added, done, canceled, started.** The issue's `createdAt`, `completedAt`, `canceledAt` or `startedAt` falls in the window. **Net** is added minus done minus canceled.
- **Stale.** An issue in Triage or Todo with no update for 5 days.
- **Needs attention.** An Urgent or High issue that's open and has no assignee, has sat in Triage for 2 days, or is In Progress with no update for 5 days.

People are matched by first name or email prefix. Linear assignees are matched directly, PR authors by their GitHub profile name, and Slack messages by a leading `Name:` or else the posting account.

## What the digest guarantees

- **Every source is read on every run.** The workflow makes the calls, not the model, so none can be skipped. A source that fails is named in the header, with its error.
- **The numbers are exact.** Counts, ages and the attention list come from code, from the same data each time.
- **The model can't invent findings.** Every plan check, mismatch and question must cite a ref the sources returned, or `check_brief` drops it.
- **A restart doesn't repeat work.** Every MCP call and model call is checkpointed. If the worker stops mid-run, the run resumes from the last completed step.

## Set up the sources

| Source | How it's read | What you need |
|---|---|---|
| Linear | MCP at `https://mcp.linear.app/mcp/readonly` | A read-only personal API key, `LINEAR_API_KEY` |
| GitHub | MCP at `https://api.githubcopilot.com/mcp/`, plus REST for profile names | `GITHUB_TOKEN` with read access to the repos. A fine-grained token works for an organization's repos, while a private repo on a personal account needs a classic token with `repo` scope. |
| Slack | MCP at `https://mcp.slack.com/mcp` | A Slack app with MCP access turned on (Slack tells you where if it isn't), installed to the workspace with channel read scopes. Its User OAuth Token is `SLACK_MCP_TOKEN`. |
| Google Meet notes | The Drive API, through two AGNT5 tools | A service account with no project roles. Share the meeting-notes folder with its email as Viewer. Locally, `GOOGLE_APPLICATION_CREDENTIALS` points at its key file. |
| Models | `openai/gpt-6-luna` | `OPENAI_API_KEY` |

Google's own Drive MCP server needs the Workspace Developer Preview, so this recipe reads Drive through the API instead.

Then say which team, repos and channels to read.

```bash title=".env"
LINEAR_TEAM=Engineering
GITHUB_REPOS=acme/api,acme/web
SLACK_CHANNELS=C0123ABCDEF   # channel IDs, not names: Slack's MCP tools take IDs
```

## Run it locally

```bash
agnt5 dev
agnt5 run workflow standup_digest --input '{"date": "2026-10-22"}'
```

`date` is `YYYY-MM-DD` in UTC. Leave it out for today. A run reads about 200 issues, a few dozen PRs and the channel's messages in under 5 seconds, then spends most of its 50 to 60 seconds on the two agents. It costs under half a cent in model calls.

## Use it from Claude or Codex

Publish the workflow as the only tool on an [MCP server](/docs/run/mcp-servers.md), so a client can't pick some sources and skip others.

```python title="server.py"
standup = MCPServer(
    "standup",
    title="Team standup",
    instructions=(
        "Use standup_digest to get the team's standup brief. It always reads every source "
        "and says which ones failed. Pass date as YYYY-MM-DD for a past day."
    ),
)
standup.add_workflow("standup_digest", standup_digest, annotations={"readOnlyHint": True})
```

A worker only publishes the servers in modules it imports, so import it from the entry point:

```python title="app.py"
from standup_digest.server import standup  # publishes the "standup" MCP server
```

Then set the variables from `.env` as project secrets and deploy. In production, set `GOOGLE_SERVICE_ACCOUNT_JSON` to the key file's contents instead of a path.

```bash
set -a; source .env; set +a

for k in OPENAI_API_KEY LINEAR_API_KEY GITHUB_TOKEN SLACK_MCP_TOKEN GOOGLE_SERVICE_ACCOUNT_JSON; do
  printf '%s' "${!k}" | agnt5 secrets set --name "$k" --type api_key --stdin
done
for k in LINEAR_TEAM GITHUB_REPOS SLACK_CHANNELS; do
  printf '%s' "${!k}" | agnt5 secrets set --name "$k" --type other --stdin
done

agnt5 deploy --env production
```

Run it in bash. Each value is read from `.env` and piped to `agnt5 secrets set --stdin`, so it never appears on screen or in your shell history. In zsh, write `${(P)k}` in place of `${!k}`.

The deploy prints the server's URL, `https://api.agnt5.com/mcp/<project-ref>/production/standup`. Add it in Claude as a custom connector and sign in with AGNT5 (see [Serve your project over MCP](/docs/run/mcp-servers.md)). Then ask questions like these.

- "Get the standup digest for 2026-10-22."
- "Who is blocked, and what should I ask on the call?"
- "Which urgent tickets have no owner?"

To use it from Codex (or Claude Code), write the client's config from the project directory, then sign in when the client asks.

```bash
agnt5 mcp connect standup --env production --client codex
```

Pass an explicit date when it matters. The window is in UTC, so "today" can be a different day than yours.

## Debug in Studio

Each question is one run. To see what happened, click **Open in AGNT5** on Claude's run card, or open the run from **Runs** in Studio. The trace shows every step, including each Linear, GitHub and Slack call, the Meet reader's Drive calls and the writer agent, with its input, output and timing. If a source failed, open its step to see the error.

## Adapt it

- **Replace** the sources (each is one `read_*` helper and its steps), the prompts in `agents.py`, and the scoreboard rules in `digest.py`.
- **Keep** one step per external call, the stable `key=` on parallel steps, and the ref check in `check_brief`. Those are what make the digest complete, replayable and honest.
- **Matching people.** People are matched by first name, so if two teammates share one, match on email instead.

## Extend it


  - [Serve your project over MCP](/docs/run/mcp-servers) — Publish workflows as MCP tools and connect them to Claude.
  - [Workflows](/docs/build/workflows) — Steps, parallel work, keys and replay.
  - [Agents](/docs/build/agents) — Give an agent tools and run it inside a workflow.

