Swan's engineering team describes the four-stage automated pipeline behind Cygnet, an AI agent that reads Slack mentions, enriches vague tickets, writes code, and opens pull requests unattended.
Adapted from @skwp# How Swan Built Cygnet: An AI Coworker in Slack that Ships Code We built a magic Slack coworker that can act as a thought partner in Slack threads, manage projects, and write code unattended at Swan. Here's exactly how it works (bookmark this and feed it to your agent): Last month, one of our engineers typed a single message in Slack: @cygnet work on ISSUE-527. Then he went to lunch. By the time he got back, there was a pull request waiting for review. The code was clean. The tests passed. The PR included a detailed explanation of every decision the agent made and why, along with detailed diagrams. He got a review from another Swan, merged it, and moved on to the problem he'd actually been thinking about all morning. But you can’t just download Claude Code and get these kind of results...It took months of tinkering. Turns out the hard part of AI coding isn't the code... it's the scaffolding that makes unattended execution safe, auditable, and cost-predictable. We built Cygnet because we noticed something that probably sounds familiar to any engineering team: the backlog is full of tickets that everyone understands and no one has time for. Fix this edge case. Add this validation. Extend this API. Each one takes a senior engineer hours, not because the code is hard, but because understanding the codebase well enough to write it takes time, and testing the change thoroughly, including manual QA, is not trivial. The cost of context switching between project work and bugfixing work is quite high. Many classes of problem are well specified: bug fixes, library upgrades, framework rewrites, refactors, and even many simple feature implementations. And they pile up, week after week, because engineers need that context transferred to them, and transferring context is one of the most expensive things we do as humans. Our team was inundated with Slack threads that often died on the vine. Decisions were difficult to make because multiple humans would discuss a problem and then go off to do additional research, ultimately realizing that the problem was too complex to solve quickly. The context on the research would be lost to local machines. We asked a simple question: what if an AI agent could be our coworker, with infinite capacity to help us problem solve, organize project, and ship? That's what Cygnet does. Here's how we built it. ## The Architecture: Four Stages, One Slack Message The entry point is dead simple. An engineer types @cygnet work on ISSUE-123 in Slack. What follows is a four-stage automated pipeline: Slack Mention → Stage 1: ENRICH (no code — read, explore, specify) → Stage 2: IMPLEMENT (write code, self-review) → Stage 3: TEST & REPORT (run tests, write proof-of-work) → Stage 4: LINT & FIX (compile/lint check + automated fix loop) → Push + PR creation → Quality pipeline (risk gate + Claude review) → Human reviews PR Stage 1: Enrich is the most important design decision in the entire system. Before Claude writes a single line of code, it reads the vague Linear ticket, explores the codebase (or multiple codebases if needed), and rewrites the ticket into something a competent engineer could execute without asking questions. That means a complete business specification, files to modify, implementation patterns to follow, a test plan, and a structured prompt for the next stage. This mirrors what a good senior engineer does when picking up a ticket. They don't start coding immediately. They investigate first. They read the surrounding code. They check for patterns the rest of the team has already established. Only then do they write. Enrichment also does something subtle: it recommends a Claude model for Stage 2 based on ticket complexity. A simple bug fix might only need a smaller, faster model. An architectural change warrants the most capable one. Stage 2 reads this recommendation and adjusts — the pipeline self-configures based on what it discovers. Stage 2: Implement runs the full implementation against the enriched ticket. Claude implements the code, performs a self-review, and writes handoff artifacts — a structured summary and a proof-of-work document — to a shared directory on the runner for the next stage to read. Stage 3: Test & Report reads those handoff artifacts, runs the actual test suite against the implemented code, fixes any test failures it can, and finalizes the proof-of-work. That document later gets posted as a PR comment, giving reviewers immediate context: what the agent did, why, and what edge cases it considered. The Proof of Work process is a Swan specified set of skills that ranges from capturing test output, to command line smoke tests, to screenshots of the user experience, or even videos. Stage 4: Lint & Compile is fully automated — no Claude involved. The workflow runs the repo's lint and typecheck commands and checks the exit codes. If anything fails, it hands the structured error output to Claude with a single focused task: fix these errors, commit, don't touch anything else. This is the only stage where the prompt is constructed inline by the workflow rather than from a prompt file, because it's purely mechanical — structured error data being handed to a narrow task. Human review is always required. We never auto-merge. The point of Cygnet isn't to remove humans from the loop — it's to hand them a finished PR instead of a vague ticket. ## The Slack App: Routing Is the Hard Part The Slack app is the always-on brain of the system. Its job sounds straightforward: classify every inbound message and route it to the right mode. In practice, this turned out to be one of the trickier pieces to get right. Cygnet supports three modes, and is growing daily: Cygnet Code — implement a Linear ticket, open a PR Cygnet Explore — answer a code question by dispatching a GitHub Actions workflow Cygnet Chat — direct Claude agent session for everything else Routing happens in four stages: Regex fast path. If the message matches action verbs plus a ticket ID, it's immediately classified as a code request. No API call needed. Fast and cheap. AI classification. For ambiguous messages, we call Claude's smallest, fastest model. It returns one of four classifications — implement, freeform code, explore, or chat — along with a confidence level and reason. Token resolution. Code and Explore modes require the user's own GitHub OAuth token. If it's missing, we send a connect prompt and bail gracefully. A serverless function in a secure execution environment handles the handshake with the core systems we want to connect, so that we are connected with the user's own identity. Slack is where multiple humans collaborate with agents. We ping Cygnet into threads and ask it questions. Explore mode turns on: it knows everything about all our code bases, our projects in Linear, and various knowledge in Notion. Cygnet collaboratively plans with us in Slack, and organizes our work into projects. Cygnet is thread-context aware, so it can see everything that's being said in a thread, process it, and help us make decisions, finally storing the output in Linear for execution. When the classifier isn't sure, it falls back to Chat. The system degrades gracefully rather than guessing wrong on an expensive operation. ## GitHub Actions as the Execution Layer When Cygnet Code fires, it dispatches a GitHub Actions workflow against an ephemeral self-hosted runner. Each stage is a discrete, visible step in the Actions UI. The Custom Runner Image The runner isn't just a stock GitHub Actions machine. It boots from a custom image built by a separate image-build workflow that runs on a schedule throughout the day. The image build does everything that would otherwise slow down a job: - Clones all target repos - Reads each repo's version spec files and installs the right dependencies - Installs the Claude Code CLI globally At job start, the repos are moved from the image's home directory into the job's workspace — same filesystem, so it's instant. The workflow then checks each repo's lockfile against its HEAD commit: only repos where a lockfile changed since the image was built get a fresh install. Everything else runs immediately. The result is that the environment setup takes under 30 seconds. For a 2+ hour agent session the total saved time is modest, but for developer feedback — watching an Action kick off in Slack and seeing Stage 1 start almost immediately — it matters. One detail that took effort to get right: the image cleanup step strips all auth before the snapshot is taken. No credentials in the image. At job start, a fresh short-lived token is generated, used to authenticate the GitHub CLI and git's credential store, and written to a secure location on disk. Commits are signed by the GitHub App bot identity, not the runner user — so the commit history shows the bot as the author, making agent-created commits visually distinguishable from human ones. Input Modes The workflow accepts two mutually exclusive inputs: a Linear ticket ID and a freeform custom prompt. Ticket mode is the common case — the Slack app dispatches with a ticket ID. Custom prompt mode is for ad-hoc requests: if you provide free-text, the workflow creates a Linear ticket first (using the first line as the title), assigns it to the right team, then switches to ticket mode for all subsequent stages. The ticket creation ensures everything is tracked in Linear regardless of how it was initiated. Parallelism and Timing One of the less obvious optimizations: the Docker test environment starts in the background during Stage 1. The test stack (Postgres + Redis + app container) takes 2–3 minutes to boot. Stage 1 (enrichment) also takes 2–4 minutes. By starting the test containers as a background process right before Stage 1, the test environment is ready by the time Stage 2 finishes implementing code and Stage 3 needs to run tests. Stage 3 has an explicit wait step — up to 5 minutes — polling for the Docker health check to pass. In practice it almost always passes immediately because enrichment gave the containers enough time to start. Retry Detection and Idempotency Before running any Claude stage, the workflow checks all repos for existing agent branches matching the ticket ID. If a branch exists but has no PR yet — which happens when a run was interrupted after implementation but before PR creation — the workflow skips straight to creating the PR from the existing branch. This makes the pipeline idempotent: re-triggering a stuck run doesn't restart from scratch, it picks up where it left off. If a PR already exists, the workflow logs it and exits cleanly. No duplicate PRs, no duplicate implementations. Concurrency Control GitHub Actions' concurrency controls prevent two runs for the same ticket from racing each other. A second dispatch for the same ticket queues rather than cancels the running job. The retry detection above then catches it and skips the work. Stage Handoffs Stages communicate through files in a shared directory on the runner, pre-created at job start. Stage 2 writes a summary of what it did — the branch name, what was changed, and the proof-of-work — and Stage 3 reads that to orient itself. If Stage 2 crashed and left nothing behind, Stage 3 falls back to git log discovery. The pipeline degrades gracefully rather than failing hard. After push and PR creation, the proof-of-work document gets posted as a PR comment. This gives reviewers immediate context: what the agent did, why, and what edge cases it considered. Session Introspection Every run ends with an introspection step that runs regardless of success or failure. It extracts key events from the output logs — tool errors, permission denials, Claude's reasoning text between tool calls — feeds them to Claude's smallest model with a focused prompt, and appends the result to the GitHub Actions job summary. The summary covers what was done, problems encountered, assumptions the agent made autonomously, and workflow improvements suggested by the session. It's a lightweight retrospective on every run. Over time, it's surfaced real patterns: certain permission gaps that recur, tools that fail in specific environments, enrichment assumptions that turn out wrong. Ingesting these summaries into followup agent work is how we've tuned the pipeline. Cygnet can tune itself. ## The Quality Pipeline When an agent branch gets a PR created against it, a second workflow triggers automatically. This is a separate pipeline that runs independently of the code pipeline. Risk Classification The first job in the quality pipeline is a risk policy gate. It reads a policy config file from the target repo — a JSON file that maps glob patterns to risk tiers: critical, high, and standard. The gate fetches the list of changed files, matches each against the glob patterns, and determines the highest tier hit. It also enforces size limits — a critical-tier PR that touches too many files gets flagged. Repos that don't have a policy file default to high tier with review required. Safer to over-review than under-review until a team opts into the full policy. The gate creates a check run on the PR with the result. Branch protection rules can require this check to pass. Claude Code Review For high and critical tier PRs, a Claude session reviews the diff. The review focuses on security vulnerabilities, logic errors, missing error handling at system boundaries, test coverage gaps, and breaking API changes. Findings are structured blocks that get parsed and posted as inline PR review comments — pinned to the exact line in the diff. For certain repositories, we provide additional review steps by Gemini and/or Github Copilot. Using different LLMs yields different results because of their slightly different "personalities"; they catch different classes of problems, which is helpful. A human reviews the output of multiple LLMs that have reviewed the change. Every bot comment includes a hidden SHA marker so review comments are inherently versioned. If the PR is updated and a new commit is pushed, the old comments are visually stale. A remediation loop (in progress) will use these markers to distinguish which comments still apply. The review creates a check run: clean for no findings, action required if issues exist. Auto-Resolution After a clean review, the pipeline automatically resolves any bot-only review threads. Threads where every comment is from a bot account get resolved via Github API calls. This keeps the PR interface clean — a human reviewer sees a PR with no unresolved threads, not a PR covered in stale bot noise. Preventing Self-Approval When the bot creates a PR, it posts an attribution comment identifying the human who triggered the run. This comment is central to a review gate that runs across all our repos: the gate reads the attribution comment, extracts the requester's identity, and then requires at least one approval from a human who is not that person. The person who said "fix this" cannot be the only one who approved the result. There are a few details that make this robust. First, the gate reads only comments that haven't been edited — if the requester tampers with the attribution comment to change the recorded username, the gate fails with an error about tampering rather than silently accepting a manipulated identity. Second, the gate re-runs on every new push to the PR, so approvals are only counted if they're against the latest commit SHA — a stale approval from before a new push doesn't carry over. Third, bot approvals are excluded entirely. This logic lives in a single reusable workflow referenced by every repo. There's one source of truth for the gate — no divergence, no gaps. ## Security: Defense in Depth Cygnet runs unattended with access to real codebases, but no production credentials to access production data or systems. We took security seriously from day one. If you're going to let an AI agent run unsupervised in your infrastructure, you need to design for the possibility that it will do something you didn't expect. Not because you distrust the model, but because prompt injection is real, tickets can contain adversarial content, and mistakes happen. The goal is to ensure that no single failure is catastrophic. We apply controls at four layers: Layer 1: Claude tool deny list. Our Claude settings block specific shell operations at the Claude Code level — privilege escalation commands, filesystem permission changes, network scanning tools. Even if something else goes wrong, these commands simply can't run. Layer 2: Runner user hardening. At image build time, the runner user is removed from privileged groups. At job start, this is re-applied as a runtime safety net, after legitimate setup steps that need elevated access. Claude cannot escalate to root. Layer 3: Network egress filtering. Before any Claude stage runs, we apply firewall output rules. Only specific ports and destinations are allowed: DNS, HTTPS, SSH to GitHub, local test services, and loopback. Everything else is dropped. An adversarially-prompted Claude session cannot exfiltrate data to an arbitrary endpoint. This is probably the most important layer — it means even a fully compromised session can't phone home. Layer 4: Prompt guardrails. The runtime machine-local context file includes an explicit security constraints section listing prohibited operations. This is redundant with the infrastructure controls, and that's intentional. Defense in depth means every layer assumes the others might fail. We understand that prompt injection into an LLMs is generally an unsolved (and possibly unsolvable) class of problem, so we do not put trust in this layer; it's just part of defense stack. There's also a pre-flight security screening step that runs before any Claude stage. Both custom prompts and Linear ticket content are sent to a classifier backed by Claude's smallest model. It looks for prompt injection attempts, requests for system file reads, credential extraction, and other adversarial patterns. If it flags the content, the job fails before Claude ever sees it. A transient Linear API failure is treated as a warning, not a block — a ticket being temporarily unreachable shouldn't stop real work. Finally, the workflow runs in a protected GitHub environment with branch restrictions: jobs are only permitted to run from the main branch. This prevents someone from modifying the workflow file on a feature branch to weaken security controls, then triggering it directly. The Slack app has its own security layer on top of all this: a pre-flight screen that catches attempts to extract environment variables, config files, or credentials before the query ever reaches the agent. ## Prompt-as-Code One rule we enforced from the start: prompts live in files, never in scripts. All Claude instructions live as versioned skill and command files. Workflow scripts invoke them by name, never by embedding prompt text inline. This means prompts are version-controlled, reviewable, and testable independently of the infrastructure that runs them. When a prompt changes, you see it in the diff. When it breaks, you can bisect it. This became especially important as the commands grew complex. The headless implementation command is a fork of the interactive version — same business logic, but with six human confirmation gates removed. Having these as separate files makes the difference explicit and auditable. You can open both side by side and see exactly what changed between "interactive" and "unattended." ## Cost Controls Unattended AI agents can get expensive fast. If you've ever left a session running without a budget cap, you know the feeling of checking back an hour later and seeing a number you didn't expect. We built circuit breakers in from the start: - Per-stage max turns at each of the four stages - Higher turn limits for tickets that span multiple repos - Per-run budget abort if costs exceed a threshold - Daily budget cap across all runs - Concurrent run limit to prevent pile-ups Default model appropriate to each stage's needs (more capable for enrichment and implementation, faster for testing and review) Typical cost for a standard ticket — enrich plus implement — runs $3–6. A complex feature might hit $5–10. Not free, but significantly cheaper than the engineering hours those tickets would otherwise consume. We use a dedicated API key with a monthly cap set in the console. This gives us clean spend attribution and an independent hard stop, separate from our application-level limits. Belt and suspenders. Per-stage model selection also gives a cost lever. The enrichment stage recommends the right model for implementation based on ticket complexity — a low-complexity bug fix doesn't need the most capable model. This recommendation is parsed from the enrichment output and used to override the Stage 2 default, unless the caller explicitly set a model. ## What We Learned Enrichment is the leverage point. The quality of Stage 2 output is almost entirely determined by Stage 1. A vague ticket produces mediocre code. A well-specified ticket — with file paths, patterns to follow, and a test plan — produces code that looks like it was written by someone who actually knew the codebase. We tried many different architectures before settling on the two-stage design. It was our most impactful decision by far. Routing and maintaining Slack context is key. The four-mode classifier is one of the more complex pieces of the system, and it directly affects user experience. Getting the model to confidently distinguish "how does auth work?" (Explore) from "refactor the auth module" (Code) requires careful prompt engineering and edge case testing. The native "@Claude" slackbot notoriously does not do a good job with routing, leading it to the wrong repository, or wrong action, without context. Cygnet is tuned on Swan's own knowledge, and is slack thread-context aware, so that it can collaborate in real time with other coworkers in a slack thread. This is a very powerful mode of communication, where multiple humans can collaboratively steer an agent, questioning its assumptions and developing context in a thread. Once the context is developed, it can easily turn a long conversation into a multi-ticket Linear project for execution. Defense-in-depth matters even when you trust the model. We trust Claude. We also know that prompt injection is real, that tickets can contain adversarial content, and that mistakes happen. The four-layer security model means any single failure is contained. No individual failure is catastrophic. That's the design goal. Headless-first is a design philosophy. The decision to fork the interactive command into a separate headless command — rather than adding a flag to the existing one — kept both workflows clean. Interactive commands need confirmation gates. Headless commands need to bail out, not prompt. Mixing these creates a command that does neither well. We learned this the hard way before splitting them. Stages communicate through files, not return values. The handoff between stages via shared artifacts was a deliberate choice over passing data through environment variables or workflow outputs. Files are easy to inspect, easy to log, and survive a stage crash. When something goes wrong, you can open the artifact and immediately understand what happened. That debuggability turned out to matter more than we anticipated. Build the observability first. The session introspection step — the model reading its own logs to write a retrospective — was added relatively early, and it's paid dividends. Patterns that we never would have noticed by manually reading logs became obvious once every run had a structured summary. It's also just good discipline: if you're running unattended code in production, you need to know what it's doing. ## Build Your Own If you're building something similar, here's the core insight we'd offer: the hard part isn't getting Claude to write code. Claude is already remarkably good at that. The hard part is building the scaffolding that makes unattended execution safe, auditable, and cost-predictable. Get that right first. The code generation is the easy part.