projects
← All projects

CAS Coach

A personal coaching assistant built on the Claude Agent SDK and Taskwarrior. The engineering underneath is a general harness for putting a model in charge of a stateful command-line tool: a supervisor that injects time, memory, and task context into every message, diff-based to keep token use down, and a sandboxed tool layer that lets the model drive a real task manager safely.

Winter 2025-2026
  • Python
  • FastAPI
  • Claude Agent SDK
  • React
  • TypeScript
  • SQLite
  • Taskwarrior
  • WebSockets

Overview

CAS Coach is a chat assistant for planning and tracking work. It drives a real task manager, Taskwarrior, and keeps a separate store of longer-lived context about how a person works. The coaching behaviour is just a system prompt. The parts worth documenting are the harness underneath it, which is a fairly general pattern for putting a model in charge of a stateful command-line tool: how each message is assembled, how the model is given enough context without spending the whole token budget on it, and how it is allowed to run task commands without being able to do damage.

The chat, with a live Taskwarrior sidecar. The reply is built from injected task context; the sidecar reflects the same tasks. Synthetic data.

Architecture

Two processes. A React single-page app talks over HTTP and a WebSocket to a FastAPI backend. The backend owns a SQLite database, shells out to the Taskwarrior CLI, and embeds the Claude Agent SDK, which runs the model and mediates its tool calls.

message+ contextstreameventsWebSocket + HTTPmemory · statesidecar · exporttask toolBrowser · ReactChat (streamed)Task sidecarMemory viewCoach · FastAPISupervisorbuilds <context>time · memories · task diffClaude Agent SDKClaude modelMCP toolstask · memorySQLitesessions · messagesmemories · snapshotsTaskwarrior CLIsandboxed: rc: blocked, hooksTaskChampion replica (SQLite)
One message makes this round trip. The supervisor prepends a context block; the SDK runs the model and any tool calls; the reply streams back as typed events.

A single message makes the round trip above. The supervisor assembles a context block and prepends it, the SDK runs the model and whatever tool calls it makes, and the reply comes back to the browser as a stream of typed events (thinking, tool_use, tool_result, text, usage) rather than one blob at the end.

Context injection

Every user message is wrapped, before it reaches the model, in a <context> block holding three things that all change between messages and so cannot live in the system prompt: the current time, the memories the system holds about the user, and the current state of their tasks.

<context>
<current-time day="Friday">2026-02-20T14:00:00+00:00</current-time>
<memories>
  <profile>
    <memory id="c0ffee12-...">Backend engineer; works from home on a flexible schedule.</memory>
  </profile>
  <preference>
    <memory id="3f9a7b40-...">Prefers a short, ranked plan over an exhaustive list.</memory>
  </preference>
  <pattern>
    <memory id="a1b2c3d4-...">Most focused between 9am and noon; energy dips after lunch.</memory>
  </pattern>
</memories>
<tasks type="diff">
  <added>
    <task uuid="9f2a1c">
      <description>Finish Q3 planning doc</description>
      <project>work</project>
      <priority>H</priority>
      <due>20260220T210000Z</due>
      <due_relative>in 7 hours</due_relative>
    </task>
  </added>
</tasks>
</context>

I've got a few things hanging over me this afternoon. What should I start with?

The model is told to use this silently and to reach for its memory and task tools when the context is not enough. In practice the reasoning leans on it directly:

The same reply with its internals expanded. The thinking refers to the injected task context by hand; the tool call is a real Taskwarrior read.

Diff-based task context

Task state is the part that would get expensive. Re-sending every pending task on every message spends tokens on data the model already has. So the first message in a session sends a full snapshot:

<tasks type="snapshot">
  <task uuid="9f2a1c">
    <description>Finish Q3 planning doc</description>
    <priority>H</priority>
    <due>20260220T210000Z</due>
    <due_relative>in 7 hours</due_relative>
  </task>
  <task uuid="4b7e02">
    <description>Review PR #128</description>
    <priority>M</priority>
  </task>
</tasks>

and after that, each message sends only what changed:

<tasks type="diff">
  <added>
    <task uuid="7c1d55"><description>Book dentist appointment</description></task>
  </added>
  <modified>
    <task uuid="4b7e02"><status>completed</status></task>
  </modified>
</tasks>

A few details make the diff trustworthy. Fluctuating fields like Taskwarrior’s urgency are excluded from the comparison, so they never surface as false changes. A full snapshot is re-sent every tenth message, so the model’s picture cannot quietly drift out of sync. High-priority tasks are always included even when unchanged. And when nothing has changed at all, the block is omitted.

A sandbox for the task tool

Handing a model a task manager is the risky part, so the task tool is not a shell. It is a single Python wrapper that is the only thing allowed to call task, and it runs the CLI with shell=False (no shell interpretation) against a project-local data directory rather than the user’s real Taskwarrior. Two layers guard what can run:

  • The wrapper rejects any argument containing rc: or rc., the syntax Taskwarrior uses to override its configuration from the command line.
  • A Taskwarrior on-launch hook blocks the dangerous subcommands outright (execute, edit, config, import, purge, undo), so a command that got past the wrapper is still refused by Taskwarrior itself. A read-only mode narrows this further to export only.

Tasks are addressed by UUID rather than the short integer IDs Taskwarrior renumbers as the list changes, so a reference the model makes in one message still points at the same task later.

Keeping the sidecar live

The task panel to the right of the chat never keeps its own copy of the tasks. It reads them by asking the backend to run task <filter> export, which reads Taskwarrior’s TaskChampion replica, the SQLite store Taskwarrior 3 keeps. Writes arrive from two places: the model’s task tool, and the sidecar’s own done, start, and stop buttons. Both go through the same task CLI, so there is one source of truth and no second write path to keep consistent.

Staying live takes one hook. Any write fires Taskwarrior’s on-exit hook, which POSTs to the backend, which broadcasts a taskwarrior_updated event on a second WebSocket. Every connected sidecar hears it and refetches. So a task the model completes mid-conversation appears in the panel a moment later, and a task ticked off in the panel is visible to the model on its next turn.

runs task (write)on-exit hook/tasks/ws updaterefetch (task export)Writerschat agent · sidecar buttonsTaskwarrior CLITaskChampion replicaCoachbroadcasts the changeSidecarReact task panel
One store, two writers, one notification path. Every write goes through the same CLI; the on-exit hook is what keeps the panel live.

Talking about tasks in the chat

Because the model is handed task UUIDs in its context, it can point back at specific tasks in its replies, and two small tag formats turn those references into interactive UI. A <task-ref> tag resolves to a live task chip: status as a colour, priority as a badge, the full task on hover. A <task-filter> tag renders as a button that applies a filter to the sidecar and opens it. The model uses the first to name a task without pasting a UUID at the reader, and the second to offer a view rather than describe one.

Two <task-ref> chips (with status dot and priority badge) and a suggested <task-filter> button in one reply. Synthetic data.

Memory

Tasks cover things to do. Memory covers what the coach needs to remember that is not a task: who the user is, how they like to work, and patterns worth noting. It is a small typed store (profile, vision, preference, pattern, strategy), exposed to the model as CRUD tools and to the user as a dashboard they can edit.

The memory dashboard. Entries are grouped by type and are user-editable. Synthetic data.

Memories are written explicitly by the model rather than mined automatically from the conversation, and the user can see and delete anything that has been stored. The injected <memories> block above is this store, formatted for the model on every message.