Defines the shared contract (SSE v2 tool events, artifact kinds and storage, artifacts API, uploads), five parallel tasks with file ownership, merge order and verification, and removes the quick-ask window from the desktop plan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 KiB
Cowork UI (phase 2) plan
Status: proposal, awaiting approval. Parent doc: desktop-client.md.
Goal: make the agent's work visible and useful. Tool calls appear as a live
activity timeline. Results that are more than text (budget breakdowns, recipes,
notes, documents) open in a side panel. Files can be dropped into the
conversation. This is all built in raggr-frontend and the Quart backend, so
the web app gets it too; the Tauri shell needs only a one-line drag-drop
setting.
Where we are
stream-querySSE sendstool_start {tool}andtool_end {tool}and nothing else: no id, inputs, duration or result. The UI shows a one-line pill whose label comes from a staleTOOL_MESSAGESmap inuseChat.ts.- Tools return formatted strings.
on_tool_endsaves them asspeaker="tool"messages, andGET /conversation/<id>returns only their first line ([tool result]). - Only
simba_searchusesresponse_format="content_and_artifact", and its artifact is a list of LangChainDocuments that nothing reads. - Auth: the backend accepting access tokens is merged (#57). The frontend
half (
fix/access-token-auth) is waiting on a deploy. - #55 (YNAB mirror) is open. It replaces every YNAB tool (new
blueprints/ynab/tools.py:ynab_budget_summary,ynab_account_balances,ynab_search_transactions,ynab_spending_report,ynab_category_budget,ynab_recent_changes). ⚠️ Its migration is numbered16_…, which clashes with #57's16_…_add_oidc_statesalready on main, so it must be regenerated before #55 merges.
Contract
Every task codes against this. It lands on main with this doc before any worker starts, so all workers share it.
SSE events (additive; old clients ignore new fields)
// tool_start
{ type: "tool_start", run_id: string, tool: string, label: string,
input: Record<string, string | number | boolean> } // values truncated to 200 chars, `config` dropped
// tool_end
{ type: "tool_end", run_id: string, tool: string, label: string,
status: "ok" | "error", ms: number,
artifact: ArtifactRef | null }
// unchanged: thinking, thinking_delta, content, response, error, [DONE]
labelcomes from a server-side registry (blueprints/conversation/tool_labels.py):{tool_name: "Checking budget…"}, with the fallback"Using <tool>…". MCP tools match by prefix (homebox_,bookstack_).TOOL_MESSAGESinuseChat.tsis deleted.status="error"comes fromon_tool_error. Most tools catch exceptions and return an error string, so they reportok; that's acceptable.- Parallel tool calls are paired by
run_id.
Artifacts
A tool opts in by returning (text, artifact) with
@tool(response_format="content_and_artifact"). The LLM sees only text. The
artifact is a plain JSON-serialisable dict:
type ToolArtifact = { // what a tool returns
kind: ArtifactKind;
title: string; // "October spending by category"
data: object; // per-kind shape below; ≤ 256 KB serialised or it is dropped
source_ref?: { system: "mealie" | "obsidian" | "paperless" | "ynab" | "web";
id?: string; path?: string; url?: string };
};
type ArtifactRef = { id: string; kind: ArtifactKind; title: string }; // in tool_end + conversation payload
type Artifact = ArtifactRef & { data: object; source_ref: object | null; tool: string;
conversation_id: string; message_id: string | null; created_at: string };
V1 kinds and their data shapes:
| kind | produced by | data |
|---|---|---|
mealie.recipe |
mealie_get_recipe |
{slug, name, description, servings, prep_time, cook_time, total_time, ingredients: string[], instructions: {title?, text}[], tags: string[], image_url?} |
mealie.meal_plan |
mealie_meal_plans, mealie_todays_meals |
{start_date, end_date, days: {date, meals: {entry_type, name, recipe_slug?, note?}[]}[]} |
mealie.shopping_lists |
mealie_shopping_lists |
{lists: {name, items: {name, quantity, checked}[]}[]} |
obsidian.note |
obsidian_read_note, journal_get_today |
{path, title, markdown (≤ 50k chars), tags: string[], frontmatter: object} |
search.documents |
simba_search, obsidian_search_notes |
{query, source, results: {title, snippet (≤ 400), filename?, path?, document_type?, date?}[]} |
ynab.summary |
ynab_budget_summary |
{month, ready_to_assign, income, assigned, spent, age_of_money?, net_worth, overspent: {name, available}[]} |
ynab.transactions |
ynab_search_transactions |
{start_date, end_date, count, total, transactions: {date, payee, category, account, memo?, amount}[]} (≤ 500 rows) |
ynab.spending_report |
ynab_spending_report |
{start_date, end_date, group_by, rows: {key, amount, count?}[], total} |
ynab.category_budget |
ynab_category_budget |
{month, groups: {name, categories: {name, assigned, activity, available, goal_target?}[]}[]} |
YNAB kinds are built against #55's tools, so they wait for #55.
source_ref.url is filled where the backend knows it (Mealie base URL plus the
recipe path). Obsidian gets obsidian://open?vault=<OBSIDIAN_VAULT_NAME>&file=<path>
only if that env var is set; it's added to .env, .env.example and
docker-compose.yml.
Storage and API
- New Tortoise model
Artifact(blueprints/artifacts/models.py, tableartifacts):id UUID, user FK, conversation FK (cascade), message FK → conversation_messages (nullable, set null), tool, kind, title, data JSON, source_ref JSON null, version int default 1, created_at, updated_at. Migration generated withmake migrate-new/ aerich, adds a table only. - Created in
stream_query'son_tool_endwhenoutput.artifactis a dict with akind, right after the tool message is saved and linked to it. Non-web channels (iMessage, WhatsApp,/query) don't create artifacts. GET /api/artifacts?conversation_id=…returnsArtifactRef[]plus{tool, message_id, created_at}, withoutdata.GET /api/artifacts/<id>returns the fullArtifact. Both require an access token and are scoped to the owner (404 otherwise).GET /api/conversation/<id>: tool messages gaintool: string, label: string, artifact: ArtifactRef | null.textkeeps the header line, for older clients.- Write-back (
PATCH) is out of scope for phase 2.
Uploads
POST /api/conversation/upload(multipartfile,conversation_id):- images go through the existing
process_imageand return{kind: "image", image_key} application/pdf,text/plain,text/markdown,text/csv(by MIME type or extension) are stored atconversations/<id>/files/<uuid>.<ext>and return{kind: "file", file_key, name, chars}. PDF text is extracted with PyMuPDF (fitz, already a dependency).- max 10 MB; anything else gets a 415
upload-imagestays as is
- images go through the existing
stream-queryaccepts an optionalfile_key. The extracted text (capped at 12k chars, with a[truncated]marker) is appended to the user turn as<attached file "name">…</attached>. The saved user message gets📎 nameappended. Saved user messages get no new column.
Tasks
Each task has its own branch off main (after the contract lands), its own herdr worktree and pane, and its own Claude session started from a brief.
| # | Branch / pane | Scope | Owns (files) | Depends on |
|---|---|---|---|---|
| A | feat/cowork-stream |
SSE v2 events + label registry; Artifact model, migration, API, creation in on_tool_end; conversation payload fields |
blueprints/conversation/__init__.py, new tool_labels.py, new blueprints/artifacts/, config/db.py, app.py (register), a migration |
contract on main. Migration last, after rebasing (see sequencing) |
| B | feat/cowork-tool-artifacts |
content_and_artifact for the Mealie, Obsidian and search tools; then a second commit for the YNAB tools once #55 merges |
blueprints/conversation/agents.py, blueprints/ynab/tools.py (2nd commit), tests |
contract; #55 for the YNAB part |
| C | feat/cowork-timeline |
Activity timeline replaces ToolBubble: grouped "Simba worked · N steps · 4.2 s" block per turn, rows with icon, label, input summary, spinner/✓/✗, duration and an "Open" chip that calls an onOpenArtifact(ref) prop; restored from conversation payload on reload |
useChat.ts, new ActivityTimeline.tsx, ToolBubble.tsx (delete), conversationService.ts types, ChatScreen.tsx (message list only) |
frontend auth PR merged |
| D | feat/cowork-artifact-panel |
Right-side artifact panel: tabs for opened artifacts, artifactService.ts, a renderer per kind (CSS bars, no chart library), "Open in Mealie/Obsidian", a sheet below lg; auto-opens the newest artifact of a turn when the window is ≥ 1280 px wide, otherwise just the chip |
new components/artifacts/*, api/artifactService.ts, ChatScreen.tsx (layout + wiring onOpenArtifact) |
C merged first (wires its prop) |
| E | feat/cowork-file-drop |
Upload endpoint + file_key in stream-query; drop overlay over the whole window, attachment chip in MessageInput; Tauri disable_drag_drop_handler() so HTML5 drops reach the webview |
blueprints/conversation/__init__.py (new route + small stream_query hunk), new utils/file_extract.py, MessageInput.tsx, ChatScreen.tsx (overlay), useChat.ts (pending file), desktop/src-tauri/src/lib.rs |
rebase after A and C (shared files) |
Parallelism and sequencing
now ──► contract PR (this doc) merged
frontend-auth PR merged (after your deploy)
│
├── A feat/cowork-stream ─────────────┐ (merges 1st)
├── B feat/cowork-tool-artifacts ─────┤ (Mealie/Obsidian/search: merges anytime; YNAB commit after #55)
├── C feat/cowork-timeline ───────────┤ (merges after A)
├── D feat/cowork-artifact-panel ─────┤ (merges after C)
└── E feat/cowork-file-drop ──────────┘ (merges last)
- All five start in parallel. A and B are pure backend. C and D build
against a mock
/apiserver that speaks the contract, so they don't wait for A. - Migration order. #55's migration needs regenerating (it clashes with
#57). A's migration must be generated after whatever lands before it. A
writes the model and code first, then rebases onto the latest main and runs
make migrate-newas its final step. If #55 merges while A is in review, A rebases and regenerates. - Shared-file conflicts (
ChatScreen.tsx,useChat.ts,conversation/__init__.py) are resolved by the merge order A → C → D → E. Each later branch rebases onto main before its review. - B's Mealie/Obsidian/search commit is safe to merge before A: without A,
artifacts ride along in
ToolMessage.artifactand are ignored.
Verification per task
- Backend (A, B, E): unit tests in
tests/unit/, run withTAVILY_API_KEY=dummy .venv/bin/pytest tests/ -q(create the venv withuv sync --all-extras).- A: event shaping becomes pure functions (
tool_start_payload(event),tool_end_payload(event, started_at, artifact_ref)), which are tested, along with the 256 KB cap and owner scoping. - B: each converted tool, with its service mocked, returns
(str, dict)matching the kind's shape. - E: extraction and the 415 case.
- A: event shaping becomes pure functions (
- Migrations: a throwaway Postgres container + aerich, the same approach the auth fix used.
- Frontend (C, D, E):
yarn build, plus the mock-server + Playwright recipe (Nodehttpmock servingdist/and SSE fixtures, Playwright on installed Chrome,addInitScriptto seed tokens). Screenshots are checked by eye. Fixtures live inraggr-frontend/test-fixtures/cowork/*.json, and D owns one per kind. Don't commit the Playwright devDep or a rewrittenpackage-lock.json.
Process
- Approve this plan. I commit it on
plan/cowork-ui, open the PR and merge it so the contract is on main. - Wait for the frontend-auth PR to merge after your deploy. A and B can start right away; C, D and E start once it's in, so they use the new fetch helper.
- For each task:
herdr worktree create … --base origin/main, a.claude-brief.mdwith that task's slice of this doc, andherdr agent start cowork-<x> … -- claude "Read .claude-brief.md…". Workers commit locally and never push. - When a worker goes idle with its task done, I review:
- pass 1: I read the diff against the contract, run the tests and build, and send numbered findings to the worker.
- pass 2: same again after its fixes.
- Then I push the branch and open the PR (
tea pr create). - I don't merge; you merge, or tell me to.
- Before each review, the branch rebases on main per the merge order.
Decisions I made (say if you disagree)
- No write-back in phase 2. Editing a recipe or note in the panel and saving it to Mealie/Obsidian is phase 2.5.
- No chart library. CSS bars and tables are enough for budgets. Revisit if trends need real line charts.
- The panel auto-opens on wide windows, otherwise shows a chip.
- One attached file per message (like images today). Dropped PDFs aren't filed into Paperless yet.
- Out of scope for now: the bridge, notifications, the Keychain, and detached runs. They're phases 3–4 in the parent doc.
Open questions
- Is #55 close to merging? It decides when the YNAB artifacts happen, and its migration needs regenerating against main either way.
- What's the Obsidian vault name (for
obsidian://links), or is the link not worth having? - What's the Mealie recipe URL format on your instance
(
/g/home/r/<slug>or/recipe/<slug>)? Worker B can check against the API otherwise.