Daniel @ Skool

Zebra Code: One thread, one session

Zebra Code turns a Slack thread into a Claude Code session on the engineer's own box. The team can watch it work and answer its questions, from a desk or a phone.

11 min readClaude wrote most of this post. I picked the topic, steered the drafts and reviewed them.

Our team at Skool started adopting AI in December 2025. I started working on Zebra Code in September 2026, and a few of our engineers built it with me. This post is how it works, starting with the thread itself.

#payments · Thread
MC
Maya Chen

@Zebra Code in web, the checkout test flakes on CI about one run in ten. Find out why and fix it.

Zebra CodeAPP
Opus 5.5 · default · high effort · web
Working · 0s
    Zebra CodeAPP

    @Maya Chen one command needs you.

    Bash wants to run
    pnpm exec playwright install chromium
    It downloads a browser for the end-to-end checkout test.
    Your turn: answer the ask as Maya.
    Zebra CodeAPP

    Opened #128 fix(checkout): drive the debounce with fake timers
    -  await sleep(500)
    +  vi.useFakeTimers()
    +  await vi.advanceTimersByTimeAsync(500)
    +  await payment.settled
    MergeRequest review
    ✓ Done · 3m 52s · transcript · diff
    A made-up thread
    A made-up thread, played back. When Zebra Code asks, you answer as Maya.

    Most engineering conversations at Skool already happen in Slack threads, so that's where we put Claude Code.

    The open-laptop walk

    In May 2026, Business Insider reported on AI coders carrying half-open laptops through airports, offices and ice rinks. Closing the lid puts a laptop to sleep, and the coding agent on it stops mid-task. The carries even have names, such as the taco and the clamshell.

    We built Zebra Code so Claude keeps working without anyone doing that walk. The session runs on the engineer's own EC2 box, and the laptop is only a window onto it. Here's one long task, followed through a day:

    One long task, one day
    A refactor starts at noon. Follow it, and both developers, through the day.
    At lunch, the frazzled developer eats a sandwich one-handed over an open laptop, while the calm one eats noodles with the phone on the tableOn a crowded train, the frazzled developer clutches an open laptop with no signal, while the calm one sits and taps the phoneAt the gym, the frazzled developer runs with a laptop balanced on the treadmill, while the calm one lifts a dumbbell beside a phoneAt night, the frazzled developer lies awake beside a glowing laptop, while the calm one sleeps under a window showing a cloud server Claude Code on the laptopWith Zebra Code

    Illustrations drawn for this post with gpt-image.

    Then try it: close both lids.

    Claude Code on the laptop
      Zebra Code
        ZEBRA CODE · now
        Needs you · Bash wants to run pnpm test:e2e
        Both sessions are working.

        Why Slack

        Claude Code in a terminal suits one engineer at a keyboard, and our work rarely looks like that. The conversation about a change happens in a Slack thread. The session making the change sits in one person's terminal, where nobody else can see it.

        A long task stops when the laptop lid closes. A permission prompt waits until its person is back at the keyboard. And five tasks at once means five terminals and five branches to keep apart.

        A Slack thread already has what those sessions lack. The whole team can read its history, a mention finds the right person, and it follows each engineer to their phone. So Zebra Code makes each thread a full Claude Code session. It runs the same CLI as the terminal, with the same tools, models, permission modes, MCP servers, skills and hooks.

        A thread, start to finish

        The thread at the top of this post goes through five steps.

        1. A session starts. Zebra Code reads the repo from the message, cuts a fresh worktree from the default branch, and gives the thread its own Claude Code process.
        2. The team watches it work. One activity message shows each thinking step and tool call as it happens, and Claude Haiku rewrites a one-line summary every 20 seconds. A reply sent mid-turn reaches Claude right away, like typing into a terminal.
        3. When Claude needs a decision, the owner taps. A command the permission mode doesn't allow posts Allow, Always and Deny buttons and mentions the owner. A question gets a button per answer, and a plan waits for Approve.
        4. Claude pushes its own branch and opens the pull request. Its answer links the transcript and the diff, and ends with the turn's cost and duration.
        5. !done ends the session and removes the worktree. If the worktree holds unsaved work, it asks first.

        The thread's first message also shows the session's state, so a busy channel shows at a glance which sessions are waiting on someone:

        • Working
        • Background
        • Needs you
        • Ready
        • Done

        Bang commands steer a thread without leaving Slack. !stop interrupts a turn, and !config switches the model, the permission mode or the effort. !repo moves the thread to another repo, and !clear starts the conversation over.

        Teammates join in the way they would in any thread. They read along, and if one of them asks for something, the request becomes a card the owner can accept or decline. !handoff passes the thread to a teammate, who picks up the same conversation on their own box.

        A server and a worker

        Zebra Code is one Go binary with two roles: a server and a worker.

        Slack and the apps connect to the Zebra Code server. Each engineer's worker dials out to the server and runs one claude process per thread. Slack Channels, threads, DMs The apps iPhone, Mac, Android Zebra Code server Holds the Slack app Routes each thread to its owner's worker Holds messages for an offline worker 1 held Maya's laptop Worker claude · # payments claude · DM Own GitHub login Theo's EC2 box Worker claude · # infra claude · # web Own GitHub login Socket Mode outbound API and live socket dials out dials out
        Every arrow points the way its connection is dialled. Nothing dials in to a worker.

        The server holds the Slack app. It gets Slack's events over Socket Mode, an outbound WebSocket, so nothing listens on the internet. It keeps people, workers and threads in its own storage layer and routes each thread to its owner's worker. If a worker is offline, its messages wait there for 24 hours.

        A worker runs one person's sessions. It holds one WebSocket to the server over our private network, and it never has the Slack token. Every Slack Web API call it makes goes through the server, which passes on only calls for threads that worker owns.

        Each session is the stock claude CLI. The worker starts one process per thread with stream-json in and out. It answers the CLI's control protocol for permission requests, hooks and in-process MCP tools, which is the same protocol the Agent SDK speaks.

        Decisions we made

        Every engineer gets their own box. A shared machine would need one GitHub token for everyone, plus walls between people's sessions, and a box per person needs neither. Every commit and PR carries the engineer's own identity.

        A new thread starts in whichever repo its first message names, with no setup. On an EC2 box, a long task keeps running with the laptop closed. A worker also runs on a Mac or Linux laptop, as a launchd agent or a systemd user unit.

        There isn't much to run. Server and worker are one Go binary with seven direct dependencies. State lives in an embedded storage layer, so there's no database server, queue or cache to run.

        Making a thread act like a terminal

        We had to solve five problems to make a Slack thread behave like a terminal. Pick one:

        Asks become buttons

        Claude Code's stream-json mode pauses inside a tool call and asks its host for a decision. The worker is that host. It parks the request, posts the buttons, and writes the answer back on the tap.

        # claude → worker, one line on stdout
        {"type":"control_request","request_id":"7",
         "request":{"subtype":"can_use_tool","tool_name":"Bash",
          "input":{"command":"pnpm exec playwright install chromium"},
          "tool_use_id":"toolu_01"}}
        
        # worker → claude on stdin, once Maya taps Allow
        {"type":"control_response",
         "response":{"subtype":"success","request_id":"7",
          "response":{"behavior":"allow","updatedInput":{…},"toolUseID":"toolu_01"}}}

        Nothing reads a terminal screen, because every tool call, ask and answer arrives as JSON. The answer can come from Slack, the app's Inbox or the lock screen, and the first one wins. Always remembers the choice for the threads after it. An ask nobody answers in 2 hours becomes a deny, with a message Claude reads.

        A worktree per thread

        Every thread gets its own checkout, cut from origin's default branch after a fresh fetch. Two threads in one repo never see each other's changes.

        $ git -C web fetch -q origin
        $ git -C web worktree prune
        $ git -C web worktree add -q --detach .worktrees/web/t1793087222 origin/HEAD
        HEAD is now at 3f9c2ab  # the thread's first turn starts here

        !done removes the worktree, and asks first if it holds work no branch has. Once no open thread works in a clone, it goes bare again.

        Moving a live session

        !worker move hands a thread to another of the owner's machines in the middle of a conversation. The checkout and the transcript travel together, so Claude carries on in the same session.

        # on the laptop
        $ git bundle create -q thread.bundle fix/checkout-timer
        # on the EC2 box
        $ git bundle unbundle thread.bundle
        $ git worktree add -q --no-checkout .worktrees/web/t1793087222 fix/checkout-timer
        $ claude --resume=<the same session id> --input-format stream-json --output-format stream-json

        Uncommitted changes travel as well as commits. The Slack thread stays put, and only the machine behind it changes. In a thread's first turn, Claude can move the thread itself when the job needs something this machine doesn't have, such as Xcode.

        Workers that update themselves

        A deploy builds the merged commit once, for every platform. Each worker updates itself on its next connect.

        server  build   3f9c2ab   darwin/arm64 darwin/amd64 linux/arm64 linux/amd64
        worker  update  3f9c2ab   restart

        It's one static Go binary with CGO_ENABLED=0, so one machine builds all four.

        Replies that aren't for Zebra Code

        In a busy channel thread, not every reply is for Zebra Code. Once someone else has posted, Claude Haiku reads the owner's next reply in context and rates how sure it is.

        context  the thread's last 8 messages, 300 characters each at most
        reply    "can you also check the refund flow?"
        haiku    82
        82 ≥ 50  Zebra Code answers  ✓

        A mention always wakes it, and the check only runs once a conversation has other people in it. Below 50, the reply waits as context for the next message that is for Zebra Code. The same small model writes the live summary every 20 seconds and names each thread.

        The apps

        iOS
        iPhone, with widgets and a share sheet
        macOS
        A sidebar of every session
        Android
        Phones, and two panes on foldables

        The Zebra Code apps for iPhone, Mac and Android follow and drive the same threads as Slack. Turns play live, tool call by tool call, under the same summary Slack shows. An Inbox collects every approval, question and plan from every thread, and Allow and Deny also sit on the notification itself.

        The apps can start sessions of their own, too, and those can stay in the app with no Slack thread. A mic button types what an engineer says, minus the fillers and false starts, and they read the message before it sends.

        Pushes through Apple and Google carry at most the repo and the tool, never a prompt or code, and the app fetches the full text from our server. A deploy from the app asks for Face ID first.

        The server, the iPhone app and the Android app speak one wire format. A fixtures/ directory holds an example of every message. The Go, Swift and Kotlin tests all decode the same files, so a wire change that breaks an app fails in the same pull request.

        Limits

        Each session runs real commands on its owner's machine, so we limit who can drive it.

        A thread runs on its owner's worker, and only the owner's messages prompt it. If anyone else clicks its buttons, they get a refusal and nothing is settled. A teammate's request reaches Claude only after the owner accepts its card, and the prompt names who asked.

        A merge, a release and a push to main ask the owner, unless the owner said Always for that repo. An approval nobody answers in 2 hours is denied. And each EC2 box belongs to one owner.

        Memory across machines

        Claude Code keeps its memory files on one machine. Zebra Code keeps memory on its server, so it follows a person across boxes, laptops and threads. Each memory sits in the storage layer with its embedding, and we search it in Go, without a vector database. A memory belongs to a person or to the team, and to all repos or to one. When two disagree, the more specific one wins. The demo below has a switch for each layer:

        How should Claude run the tests in web?

        Each session starts with an index of the memories that apply, and each message brings up to 3 that match it. Claude saves what it learns, and its answer names each memory it saved. A team memory needs its author's click and then an admin's approval before anyone else's session reads it.

        A change keeps the text it replaced for 30 days, and a deleted memory waits 30 days in the trash. Memory stays off until each person switches it on.

        Built with Zebra Code

        We build Zebra Code with Zebra Code. Its first commit landed on September 20, 2026, and these are the repo's numbers for the five weeks to October 27:

        Pull requests
        750
        since September 20
        Merged
        705
        of 750
        By Claude Code
        746
        of 750
        Lines of code
        233K
        Go, Swift and Kotlin
        Go packages
        76
        in one module
        Go dependencies
        7
        WebSocket, Slack, +5
        Tests
        2,629
        Go 1,613 · Swift 440 · Kotlin 576
        Shared wire fixtures
        151
        decoded by Go, Swift and Kotlin

        Pull requests opened, running total

        By day, September 20 to October 27, 2026

        Lines of code by language

        October 27, 2026

        Show the data as tables
        DayPull requests
        LanguageLines

        We merge and deploy from the thread, too. Claude opens the pull request and a teammate reviews the diff, and then the answer offers Merge and Deploy buttons. Deploy rebuilds the server, and every worker follows it. For an app change there's Release app, which ships new iPhone, Mac and Android builds to our testers.

        What we learned

        Making the thread the unit kept the tool easy to explain. One thread is one session and one worktree, and everyone already knows how to start, follow and close a Slack thread.

        Each session runs as its owner, with their GitHub identity, on their own box. So code review, git blame and audit logs read the way they do for any other change. An ask mentions its owner in Slack and reaches their lock screen, so they can answer it away from their desk.

        Teammates who watch the activity live catch a wrong turn early, and say so in the thread. Small calls go to a small model: Haiku decides whether a reply is for Zebra Code, writes the progress summaries and names threads. Each of those calls is cheap and fast, and that keeps the session's model on the real work.

        One set of JSON fixtures keeps a Go server, a Swift app and a Kotlin app in step. The worker also drives the CLI instead of scraping it, so it sees every tool call and ask as JSON, as the CLI sends them.

        More writing

        Building something like this, or have a question about it? Email me.