Notion → Claude Code → PR · on your machine

ticket‑runner.

You write a ticket, you move it to Ready, and a few minutes later the work is waiting for you: a pull request on its own branch — or the page itself, written. One Claude Code session per ticket, in the background, across all of your projects.

Written and maintained by Salvador Cardona · cardona.digital

$ curl -LsSf https://raw.githubusercontent.com/SalvadorCardona/ticket-runner/main/install.sh | sh
The source code
your Notion board — Tickets

The whole loop: Ready is you asking for the work, Validated is you accepting it, and Done means it is actually out. Between the two, the runner does the typing.

01 — What it is

A board that does what you put on it.

A systemd timer on your machine reads your Notion board, claims every ticket in Ready, and starts one Claude Code session per ticket. It never touches your working copy, and nothing reaches the outside world until you have moved a ticket to Validated.

A pull request, or a page

A project that names a repository gets a worktree, a branch and a PR. One that names none gets its answer written into the ticket as real Notion blocks.

Your checkout is never touched

Every ticket gets a disposable git worktree of its own. Your branch, your uncommitted files, stay exactly as you left them.

One column that says yes

Move a ticket to Validated and the runner merges its pull request — or publishes the post, the mail, the announcement — and only then calls it Done.

It answers in the comments

Reply under one of its reports and it answers, in the thread, having read the ticket, the project and the repository. It talks; it does not work.

Asks you on your phone

A ticket the agent would not guess at asks its question on Telegram or Slack. oui is the whole answer — it lands on the ticket and the next run carries on.

A console, on loopback

ticket-runner serve: the same board live, the CLI in a browser, and a chat with your whole workspace — served from 127.0.0.1 and nowhere else.

The pieces of ticket-runner and how they fit together
Everything inside the dotted frame runs on your machine. It reaches three things outside it: the Notion board, GitHub, and the channel you chose to be asked on.
02 — Installation

One command, then one page to share.

$ curl -LsSf https://raw.githubusercontent.com/SalvadorCardona/ticket-runner/main/install.sh | sh

The script checks the dependencies, installs the ticket-runner command into ~/.local/bin, asks for your Notion token, and arms a systemd timer that picks up ready tickets every 30 minutes. It also starts the web console on http://127.0.0.1:8787 and prints the address to open it with.

Then, the Notion side — three steps

  1. Create an internal integration on notion.so/my-integrations — a robot account with its own token, starting with ntn_. Give it Read comments and Insert comments under Capabilities: that is what lets it report on a ticket and be answered.
  2. Share one page with it — any page, an empty one will do: the ··· menu → Connections → your integration. An integration cannot grant itself access, so this is the one step no command can take for you.
  3. Let the runner build the rest:
$ ticket-runner init https://www.notion.so/your-page --token ntn_…
your page                  the one you shared
└── ticket-runner          the workspace: its rows are the master pages
    ├── Tickets            the tickets database, 12 columns, 2 relations
    ├── Projects           Name, Repository, Path
    ├── Agents             the roles a ticket can be handled by
    └── Context            a plain page: who you are

Plus a demonstration ticket, left unstarted, so the first thing you do is move something to Ready and watch it work. Running init again is safe — and is how you upgrade a board: what exists is kept, what a previous version did not know how to create is added.

Check that everything is in place

$ ticket-runner doctor

It checks the token, the access to the database, the type of every column and the presence of each status — and names whichever of those was missed.

Running the install command again updates the installation: the code is replaced, your configuration is kept. You will rarely need to: once an hour, a run asks whether it is still on the latest version, and updates itself between two sessions if not. runner.auto_update = false turns that off.

Requirements

  • Linux with systemd in the user session;
  • python3 ≥ 3.11 — no dependencies to install, everything is in the standard library;
  • git, Claude Code, and gh authenticated, for pull requests.

The installer’s switches

VariableEffect
TR_INTERVAL=10seconds between two runs (default: 1800, i.e. 30 min)
TR_NO_SERVICE=1no timer and no console: you run ticket-runner run yourself
TR_NO_WEB=1the console’s unit is installed, but left stopped
TR_SRC=.install from a local clone, without the network — and without self-updating
TR_REF=v1.2install a tag instead of main, and stay on it

Uninstalling

$ curl -LsSf https://raw.githubusercontent.com/SalvadorCardona/ticket-runner/main/uninstall.sh | sh

TR_PURGE=1 also removes the configuration, the logs and the history. Branches already pushed are never touched.

03 — The board

Seven columns, and two of them are gestures.

This is where your control over the system lives. The names are yours — map them under [notion.status] — and doctor checks each one actually exists on the board.

StatusWhat it means
Readythe description is precise enough for an agent to handle it alone. The gesture that triggers work.
In progressclaimed by the runner. Stops the next run from taking it again.
In reviewbranch pushed, pull request opened. Yours to review.
Validatedyou read it and said yes. The second gesture — the runner merges the pull request, or publishes what the ticket holds, and then closes it.
Donethat pull request has been merged — or the ticket produced a document, which has nothing to wait for.
Failedsomething broke: the session, the push, the worktree. Its worktree is kept for the post-mortem.
Blockedthe agent would not guess and asked a question instead. Answer in a comment and the ticket runs again.

In review rather than Done, because nothing is done when the runner lets go of a ticket: a pull request is waiting for a human, and a board that calls that Done stops being believed by the second week. Blocked is its own column rather than a shade of Failed, because a ticket waiting for you and a session that crashed are different days.

What a ticket carries

The body of the page is the brief: write there what you would tell a developer who does not know the subject. Leave the title empty and the runner writes one from the body. The rest is columns, and most are optional:

PropertyRole
Projectrelation — decides the repository, and therefore whether the ticket is code or a document
Agentrelation — decides who handles it: one row per role, the body of its page is the role
PriorityUrgent, High, Normal, Low — which ready ticket runs first
Modelopus, sonnet, haiku — this ticket’s model, over its agent’s and over runner.model
Scheduledhold the ticket until that moment — a ready ticket waits to start, a validated one waits to go out
Progresswhat the session is doing right now, rewritten every ten seconds — Bash · npm test
Sessionwritten when the ticket is claimed. Make it a URL and its cell becomes a button that opens the conversation in a terminal
Pull Request, Cost, Durationfilled by the runner at the end

Three pages, from the widest frame to the narrowest

the context page   who you are, what you work with, how you like things written
      
the project page   the conventions of THIS project — and whether it names a repository
      
the agent page     the role: “Dev front”, “Rédaction” — and, if it says so, the model
      
the ticket         the task

Whatever you write on a project page becomes standing instructions for every ticket of that project, written once instead of retyped. That is what turns “write me a post about the new tool” into your voice rather than a generic one — and, on a code project, what carries conventions that belong in no single ticket.

Following the work

While the session works, its steps are written into the ticket on a ten-second cadence: one toggle per run, a bullet per file read and per command run, and what the agent said as a paragraph of its own.

⏳ Live — 12 step(s) · 3 min
   •  Read    src/app/header.component.html
   ──────────────────────────────────────────────────────────────────
   I will remove the banner from the template and the stylesheet rules
   that went with it, then run the tests.
   •  Edit    src/app/header.component.html
   •  Bash    npm test -- --watch=false

Every ticket is a real Claude Code session — the same transcript your own sessions produce. claude --resume <id> reopens it from any directory, and keeps working after the worktree is gone. When a ticket finishes, its transcript is filed under the repository, so claude opened there lists it in its session picker.

04 — Validated

Reviewing ends in a gesture. This column takes it.

You read the pull request and it is good: someone still has to press Merge. You read the post and it is right: someone still has to publish it. That last step is pure mechanics — and it is the one part of a ticket the board always left to a human.

Notion                    ticket-runner                       what happens

In review     ──────▶     you read it. Good?
    │
    └─ yes ──▶ Validated  ─┬─ the ticket has a pull request
                           │      gh pr merge --squash              ──▶  it is in
                           │
                           └─ it has none: a post, a mail, an announcement
                                  a session, told to publish what the
                                  ticket already holds                ──▶  it is out
Done          ◀───────────  and only then
05 — Talking to it

A ticket is somewhere to talk, not only somewhere to ask.

The comments of a ticket go into the prompt, oldest first. A run asks a question, you answer it in a comment, and the next run reads your answer instead of asking again. Answering is the whole gesture: nothing to move on the board.

In the comments

Running a ticket again is the right answer to do it differently. It is the wrong answer to why did you do it like that? — so a comment under one of its reports is simply answered, in the same thread, in the language you wrote in:

ticket-runner@laptop — done.Removed the header from the dashboard. Branch ticket/supprimer-l-entete-9d2cb790 · 2 commit(s) · github.com/…/pull/12
youpourquoi une nouvelle branche plutôt que celle d’hier ?
Ticket Runnercelle d’hier était déjà partie en revue sur la PR #11, et y pousser aurait mélangé deux changements sans rapport. La nouvelle part de main, qui contient déjà #11.

It talks, it does not work. That is not a promise made in a prompt: the reply session runs under plan mode, which Claude Code cannot write from. Ask it for a change and it tells you what the change would take — which is the beginning of your next ticket. Name it — @claude — anywhere on a ticket it has run, and it answers there too.

From your phone

The two moments that need you are the two you are least likely to be at your desk for: the agent asked a question, and a pull request is waiting. So the runner writes to Telegram or Slack — and what you answer there lands on the ticket, as a Notion comment, along the exact path a comment typed into Notion takes.

   the runner                  your phone                   the ticket

   blocked ────────▶  🙋 Blocked · Le header
                      Which header — the dashboard
                      one or the public site?
                      notion.so/…
                                   │
                                   │  “celui du dashboard”
                                   ▼
                      ✓ noted on “Le header”  ──────▶  a comment on the page
                                                       │
   next run  ◀──────────────────────────────────────────┘  the ticket runs again
Telegram — the two-minute channel

No public URL, no webhook: the runner polls, which is what makes this the one channel that installs on a laptop behind a NAT.

  1. talk to @BotFather/newbot → put the token in [notify.telegram] token;
  2. open the chat with your new bot and say anything to it;
  3. ticket-runner notify --pair reads that message and writes the chat id into your configuration;
  4. ticket-runner notify sends you a first message.

Only that chat is ever read: a message from any other chat is dropped before it can become a comment on your board.

Slack — where the team already is

A question is posted to the channel and answered in its thread, which is how two tickets can be waiting at once without their answers being confused.

  1. api.slack.com/appsCreate New AppFrom scratch;
  2. OAuth & Permissions → Bot Token Scopes: chat:write, plus channels:history (or groups:history, im:history);
  3. Install to Workspace, copy the xoxb- token into [notify.slack] token;
  4. /invite @your-bot in the channel — the step everyone forgets — and put the channel ID in channel.
[notify]
replies = true                             # read what you write back
events = ["blocked", "failed", "done"]     # the moments worth a message

yes, oui, ok, go, 👍 — and their opposites — are read as a verdict and spelled out for the session that will read them. Anything else travels verbatim.

06 — The web console

Notion is where tickets are written. This is where you steer.

A second window on the same workspace, served from your own machine — running already: the installer starts it and prints the address, token included.

┌───────────────┬──────────────────────────────┬─────────────────────────────┐
│ ticket-runner │ + New ticket   board · table │  you                        │
│  v0.9.2       │                              │  Where is the SQLite ticket │
│               │  Ready     1   In progress 1 │                             │
│ ▸ Board    4  │  ┌──────────┐  ┌──────────┐  │  workspace                  │
│   1 ready     │  │ Retirer  │  │ Migrer   │  │  Six minutes in, on Trader  │
│   Live     1  │  │ le       │  │ vers     │  │  IA. It has rewritten       │
│   Settings    │  │ bandeau  │  │ SQLite   │  │  src/storage.py and is on   │
│               │  │ High     │  │ pytest   │  │  pytest. Nothing committed. │
│               │  └──────────┘  └──────────┘  │                             │
│               │                              │  > status                   │
│  live        │                    ───▶      │  timer on · 30 min          │
└───────────────┴──────────────────────────────┴─────────────────────────────┘
     the menu           the board, live                the console

The board

The Notion board, read from Notion and written back to it, drawn as the columns the board has. Drag a card into a column and the ticket moves. What it adds is the running session’s steps, live, and a validate button on every card in review.

A card is a way in

Click one and the ticket becomes a page — the brief, the report, the notes under it — with its terminal beside it: everything said on the ticket, and a field to say the next thing. What you type is a comment on the ticket — the gesture the runner already knows.

The console

A line starting with > is a ticket-runner subcommand, streamed into the page. Anything else is a message to your workspace: one long Claude Code session with your repositories under its feet.

Settings

config.toml drawn as a page — every key, from the Notion token to what your board calls its Blocked column. A save is all of it or none of it; your tokens never come back to the browser.

What is behind that port

The console is arbitrary code execution on the machine that runs it. The chat starts Claude Code sessions with the same bypassPermissions the runner uses — that is what makes it useful, and it is the whole of the risk. So it binds 127.0.0.1 and refuses any other host unless web.token is set on purpose; every request carries that token; and writes demand a header a cross-origin form cannot set.

To reach it from your phone or another machine, tunnel rather than widen:

$ ssh -L 8787:127.0.0.1:8787 <the machine running it>

The page is a React application — TypeScript, Vite, shadcn/ui — and what ships is the build, committed beside the Python that serves it. Nothing about the console asks the machine that runs it for Node: no CDN, no web font, no analytics. Node is a thing you need to change the console, not to use it.

07 — Configuration

Everything fits in one file.

~/.config/ticket-runner/config.toml — created by the installer with mode 600, opened by ticket-runner config, and drawn as a page in the console’s Settings tab.

[notion]
token = "ntn_…"
workspace = "https://www.notion.so/…"   # the database whose rows are your master pages

[notion.pages]
tickets = "Tickets"                     # required — the others are found by the title of a row
projects = "Projects"
agents = "Agents"
context = "Context"

[runner]
workspace_root = "~/workspace"          # where to look for repositories
interval_seconds = 1800                 # between two passes — `ticket-runner enable` applies a change
max_concurrent = 2                      # tickets handled side by side
timeout_minutes = 30
model = ""                              # "opus", "sonnet"… empty = the CLI's default
permission_mode = "bypassPermissions"   # see “What protects your code”
branch_prefix = "ticket/"
merge_method = "squash"                 # how a validated pull request is merged
reply = true                            # answer in the comments
progress = true                         # narrate the session into the ticket
auto_update = true

[projects]
"Trader Ia" = "~/workspace/labo/trader-ia"   # for repositories that cannot be guessed

[notion.status]
done = "Shipped"                        # if your columns have other names
failed = "Needs you"

[web]
host = "127.0.0.1"
port = 8787

The keys that change a habit

KeyDefaultEffect
runner.interval_seconds1800down to a few seconds if you want a ticket picked up as soon as you move it
runner.push / open_pull_requesttruefalse: commits stay local, or the branch is pushed without a PR
runner.permission_mode"bypassPermissions""acceptEdits" forbids unapproved shell commands — at the cost of sessions that stop often
runner.keep_worktree_on_failuretruekeep enough around to understand a failure
runner.attach_sessionstruefile each session under its project, so claude --resume there lists it
runner.session_host""the ssh destination a Session link opens, when the runner lives on a server
runner.prompt_file""your own prompt template — and document_prompt_file, delivery_prompt_file for the two other kinds of session
notion.tickets_database""one database instead of a workspace; wins when both are set

The full list, with every default, is in the repository README and in config.example.toml.

08 — Usage

Mostly, you do not run it. It runs.

The timer does the work. The command line is for looking, and for the first attempt — best made by hand, on a ticket you choose.

$ ticket-runner run --ticket https://www.notion.so/... --dry-run   # look first
$ ticket-runner run --ticket https://www.notion.so/...             # then go
$ ticket-runner init <url>   # build (or complete) the Notion board under a page
$ ticket-runner list         # the ready tickets, and their project
$ ticket-runner run          # one run, right now
$ ticket-runner logs -f      # follow the running session
$ ticket-runner status       # timer, console, current run, recent tickets
$ ticket-runner history      # what has been handled, with cost, duration and pull requests
$ ticket-runner projects     # Notion project → local repository mapping
$ ticket-runner doctor       # full diagnostics
$ ticket-runner clean --force          # remove worktrees, their branches, and scratch dirs
$ ticket-runner update       # move the installation to the newest version
$ ticket-runner enable       # apply interval_seconds, start the timer and the console
$ ticket-runner disable      # stop the timer and the console
$ ticket-runner serve        # the web console, in this terminal
$ ticket-runner notify       # send yourself a test message on Telegram or Slack

clean --force removes a ticket’s branch along with its worktree — but only when nothing on it is anybody’s but the runner’s. A branch with commits not on the base, one whose pull request is still open, or a worktree holding uncommitted changes: those are kept, named, and given the reason, since a commit that failed to push is sometimes the only copy there is.

09 — What protects your code

An agent with nobody there to stop it needs a frame.

Eight guardrails, all of them on the program’s normal path rather than in a prompt.

The main repository is never touched

Every ticket gets a disposable git worktree on its own branch. Your working copy, your uncommitted files and your current branch stay exactly as you left them — and two tickets on the same project can move at once.

The agent commits, the runner publishes

Pushing a branch and opening a PR are outward-facing gestures: they happen afterwards, once it is established that there are commits at all. A session that declares itself done without committing anything is treated as a failure.

An ambiguous ticket is not guessed

The prompt explicitly asks the agent to answer RESULT: blocked and stop rather than decide in your place. The ticket goes to Blocked with the question in a comment.

A failing ticket takes only itself down

The others in the same run carry on. Its worktree is kept for the post-mortem, and the session ID reopens the conversation exactly where it stopped. The next attempt picks that same branch up — rebased onto the base branch first — rather than starting from nothing.

Answering a comment cannot change anything

A conversation runs in the repository itself, but under plan mode, which Claude Code cannot write from. Asking it a question can cost you a minute of its time and nothing else.

Two runs never overlap

A file lock means a run that outlasts the timer’s interval is not lapped by the next one.

A ticket is never stuck for good

Any ticket still marked In progress at the start of a run was abandoned — by a reboot, a crash. It goes back in the queue with a comment saying so. One exception: a ticket abandoned mid-publication is never silently redone — it goes to Blocked asking whether the thing went out.

Nothing reaches the outside world without your column

A merge, a post, an email: the runner does none of them until a human has moved that ticket to Validated. A session cannot put itself there, and no configuration key merges a pull request the way open_pull_request opens one.

One thing to know. By default the runner starts the session with permission_mode = "bypassPermissions", because a session with nobody to ask cannot ask. The isolation comes from the worktree, not from the permission model — and it protects your repository, not the machine. Whoever can move a ticket to Ready can have commands run under the account the runner uses. On a personal laptop that is the trust you already give Claude Code; on a shared board it is a different proposition.

And one thing to weigh, if you use Validated. A publishing session uses the credentials that machine holds. Read the ticket before you validate it: validating is the trust boundary of this whole tool.

On a server

The runner is at its best where it never sleeps, and five things change when it moves off your laptop: Claude Code needs its own credentials there (or ANTHROPIC_API_KEY); gh has no keyring, so put a narrowly scoped GH_TOKEN in the unit; a [projects] entry overrides the board’s paths for that machine; session_host makes the Session links open over ssh; and Telegram or Slack stop being a convenience and become the way you hear about anything at all. Run one runner, not two — and on a shared board, a dedicated account, and editing rights limited to the people you would let run a command on that machine.

10 — Troubleshooting

Always start with the doctor.

ticket-runner doctor checks the token, the database, every column, every status, the comment capabilities, the notification channels and the version — and names what is missing.

object not found on the database

The page is not shared with the integration: ···Connections. Sharing the page that holds the workspace covers everything under it — this is the step everyone forgets, and nothing in the error hints at it.

A comment gets no answer, or a blocked ticket never wakes up

The integration cannot read or write comments: my-integrationsCapabilitiesRead comments, Insert comments. Or the comment is not addressed to it — reply under one of its reports, or name it. doctor tries it on a real ticket and says which.

A ticket became a document when you wanted code

Its project names no repository. Give the project page a Path or a Repository, or add "Notion name" = "/path" under [projects]. The runner never guesses from a project’s name: ticket-runner projects shows which is which.

Branch pushed, no pull request

gh cannot reach its credentials from a systemd service — locked keyring. Use gh auth login with a token, or set GH_TOKEN in the unit.

The timer does not fire when no session is open
$ sudo loginctl enable-linger $USER
claude: command not found in the journal

The PATH baked into the unit predates a node version change: run install.sh again.

An answer from Slack lands on the wrong ticket, or on none

A bare “oui” answers the last question asked. In a shared room, reply in the thread of the question, or paste the ticket’s link. And the bot needs channels:history to read the channel at all.

Where to look

$ ticket-runner logs -f                        # the live feed of the running session
$ ticket-runner logs <ticket id>               # a past session, rendered
$ journalctl --user -u ticket-runner -f        # the timer's own journal
$ claude --resume <session id>                 # reopen the conversation itself

Session logs live in ~/.local/state/ticket-runner/logs/, one .jsonl per ticket. The full symptom table is in the repository README.

11 — The author

Handmade, by a developer — and by the runner itself.

Most of this repository’s pull requests were opened by ticket-runner, from tickets on its own board. The releases are cut the same way.

SC
Salvador Cardona Thirteen years of web development — and the other projects at the same address.
cardona.digital