Qoffee monitors your quantum jobs, so you don't have to.
Qoffee is a free and open-source IBM quantum job monitoring and notification tool that runs as a GitHub Action in your own repository, on a schedule, under your own GitHub account. You submit a job, tag it, and forget about it. Qoffee notifies you when it's done, or when the job fails, without you ever needing to open a laptop, refresh a dashboard, or check a queue.
- Perk: Qoffee has no database.
- How Qoffee works
- How to Setup Qoffee
- Usage: Tagging a job with
qoffee - Qoffee doesn't spam.
- For Qoffee, your failed jobs matter.
- Qoffee makes sure the notification was delivered.
- Does Qoffee add another tag?
- When Qoffee gives up
- Testing for Developers
- Debugging Locally
- Notification channels
- Optional Configurations
- Triggers
- Setting up a Cloudflare Worker as a High Frequency Scheduler (Optional)
- Where Qoffee could go
- Other Vendors looked into
- FAQ
- License
The tracking state lives entirely on IBM's servers, the repo itself never stores anything about you, not your job IDs, not your history, not who's using it. That means the repo can be completely public, and anyone can fork it and run their own copy just by adding their own secrets. No cloning, no re-uploading, no private repo required. Moreover, all public repositories get unlimited Actions minutes.
Public run logs also handled. GitHub Actions logs on a public repo are world-readable. Qoffee redacts job IDs and instance CRNs to stable short hashes (
job#3beed3) before anything reaches the log, including inside exception tracebacks, which is where the IBM SDK would otherwise leak your CRN in a request URL. Redaction is on by default, because a safe default is worth more than a configurable one. The full IDs are still in your notification, where only you can see them.
Two people running identical forks produce completely separate, mutually invisible systems, purely because their secrets differ.
A quantum job is tracked because it carries a tag that you gave it. Nothing else remembers anything.
There is no database. No spreadsheet. No jobs.yaml. No local state file.
IBM's own job metadata: tags, is the entire tracking mechanism, from
discovery through to cleanup.
Submit job with tag "qoffee"
│
▼
GitHub Actions runs (schedule or manual trigger)
│
▼
Qoffee queries IBM for every job tagged "qoffee"
│
▼
Notify Discord: one batched message per run with filters to avoid spamming and redundancy.
│
▼
Terminal jobs (DONE / ERROR / CANCELLED) get
renamed "qoffee" → "qoffeed", but only AFTER
the notification is confirmed sent.
-
Fork this repo.
-
In your fork's Settings → Secrets and variables → Actions, add:
Secret What it is IBM_TOKENYour IBM Quantum API token IBM_CRNYour IBM Quantum instance CRN DISCORD_WEBHOOKA webhook URL from your own Discord server (default channel) A guide to getting it.
Add SLACK_WEBHOOK and/or NTFY_URL too if you want more than one channel. See Notification channels below.
Note: Nobody can view a secret's value once it's set; not you, not a collaborator, not anyone browsing a public repo. This isn't fork-specific: it's true for every GitHub repository, always. Forking doesn't migrate secrets, each fork's secrets are created independently by whoever owns that fork, and stay scoped to it alone.
- Actions tab → enable workflows on your fork. GitHub disables Actions entirely on a freshly forked repo until you enable them once. Optionally trigger the workflow once manually now to confirm everything connects.
That's it.
GitHub disables scheduled workflows after 60 days with no repo activity, silently, and it also disables manual dispatch until re-enabled. To prevent that, Qoffee pushes an empty "keepalive" commit every 45 days if the repo has been otherwise quiet. If you see an unexplained
chore: keepalivecommit in your history, that's why, it's expected, not a bug.
Add one line to whatever script you already use to submit jobs:
sampler = Sampler(backend)
sampler.options.environment.job_tags = ["qoffee"] #tagged
job = sampler.run([isa_circuit])You can also tag a job after submission, or tag one you didn't even submit in this session, as long as you can retrieve it:
job = service.job("your_job_id_here")
job.update_tags(list(job.tags or []) + ["qoffee"]) #taggedOptionally, you can give it a name so notifications are readable instead of showing a raw job ID, add a second tag using the name: prefix:
sampler.options.environment.job_tags = ["qoffee", "name:Bell Test 1"] #tagged and namedIBM caps each tag at 24 characters and allows 5 tags per job
the name: prefix uses 5 of those characters, leaving 19 for the label
itself.
To stop tracking a job manually, just remove or rename the qoffee tag yourself and Qoffee will simply stop
seeing it on the next run. No other cleanup needed.
# For removing the tag
job = service.job("your_job_id_here")
job.update_tags([t for t in (job.tags or []) if t != "qoffee"])
#For renaming the tag
job = service.job("your_job_id_here")
job.update_tags(["qoffeed" if t == "qoffee" else t for t in (job.tags or [])])Every run sends one batched message, not one per job. As jobs resolve, they drop out of the batch, so the message naturally shrinks over time.
Qoffee doesn't spam the same status notification as well. Qoffee tracks the last state it reported, which it encodes in the tag itself, so this costs no storage, and stays silent when nothing has changed.
A job that sits in the queue for six hours produces one message. Only when the status changes, you are notified. To learn how Qoffee avoids the notification redundancy, see Does Qoffee add another tag? below.
Also, if nothing is currently tagged qoffee, Qoffee sends no message at
all.
This is the another design decision behind Qoffee's notification logic:
| Status | Tag renamed to qoffeed? |
|---|---|
INITIALIZING / QUEUED / RUNNING |
No |
DONE |
Yes, immediately after notifying you |
ERROR / CANCELLED |
Only if nothing else tagged qoffee is still active or finished in the batch |
A DONE job clears out of the batch the moment it's reported. An
ERROR/CANCELLED job deliberately does not, it stays visible in
every batch notification for as long as anything else in the batch is still
moving. It only resolves once the whole batch has gone quiet.
The result: if you walk away for a day, the last message you see are the failures if any. An empty batch means everything succeeded. A batch that's shrunk down to just failures means those are exactly the jobs that need resubmitting.
Nothing is ever untagged without a confirmed Discord send. The sequence is always: check status → build message → send → only on a successful send → rename terminal jobs. If the Discord call fails for any reason, every tag is left exactly as it was, and everything gets re-reported on the next run instead of silently disappearing from tracking.
Yes. One extra, and it's temporary.
You add one tag. Qoffee adds a second one, rewrites it as the job moves through its life, and removes both at the end. This matters to you because IBM allows only five tags per job, so you should know how many Qoffee is using.
What the job actually carries over its lifetime:
| Moment | Tags on the job | Count |
|---|---|---|
| You submit it | qoffee |
1 |
| Qoffee sees it queued | qoffee, qoffee@Q |
2 |
| It starts running | qoffee, qoffee@R |
2 |
| It fails, and you're told | qoffee, qoffee@F:1755835200 |
2 |
| Tracking ends | qoffeed |
1 |
Qoffee has no database, so "I already told you this job was queued" has to be written down somewhere. It gets written on the job itself.
qoffee@Q: queued, and you've been toldqoffee@R: running, and you've been toldqoffee@F:1755835200: failed, and you've been told. The number is a Unix timestamp, used only by theFAILURE_AUTOCLEAR_HOURSsafety net.
On every run Qoffee compares the job's real status against the letter in that tag. Same, and it stays silent. Different, and it notifies you, then rewrites the tag. That comparison is the entire reason you aren't pinged every fifteen minutes for the same status.
IBM allows five tags per job. If a job already carries five of your own, Qoffee
cannot add its own tracking tag, and there is no database in which to record
that it tried. Rather than retry forever in silence, the run exits with code
4, goes red in the Actions tab, and names the job in the log. Remove one of
that job's tags, or remove qoffee from it, and the next run proceeds normally.
This is distinct from a transient failure. A network blip is retried quietly on the next run and the run still passes; only a rejection that can never succeed turns the run red.
pip install -e ".[dev]"
pytest -m "not live"python -m qoffee --check-config # validate everything, contact nothing
python -m qoffee --dry-run # fetch, decide, report: change nothingExit codes are distinct so a red run tells you what broke without opening the log:
| Code | Meaning |
|---|---|
0 |
Success, including "nothing tagged" |
1 |
Configuration error, caught before IBM is contacted |
2 |
IBM unreachable; no tags touched |
3 |
A required notification channel failed; no tags touched |
4 |
A job's tags can never be written; see the log for which |
Set CHANNELS in qoffee/settings.py, comma-separated, in priority order:
CHANNELS = "discord,slack,ntfy"| Channel | Secret needed |
|---|---|
| Discord | DISCORD_WEBHOOK |
| Slack | SLACK_WEBHOOK |
| ntfy | NTFY_URL (e.g. https://ntfy.sh/your-topic) |
By default, the first channel listed must confirm delivery before a job's tag is updated, that way a single broken webhook can never cause a job to silently drop out of tracking without you having actually been told. If every configured channel fails on a given run, nothing changes and the same news is reported again next run.
Everything you'd want to change lives in one file: qoffee/settings.py. It's a plain block of commented constants, no YAML, no JSON, no hidden config service.
| Setting | Default | What it controls |
|---|---|---|
RESOLVED_TAG |
"qoffeed" |
Applied once tracking stops (set "" to delete the tag outright instead) |
CHANNELS |
"discord" |
Which notification channels are active |
REQUIRED_CHANNELS |
(first channel) | Which channels must confirm delivery before tags change |
FAILURE_AUTOCLEAR_HOURS |
0 (disabled) |
Safety net: force-clear a stuck failure after N hours if the batch never goes quiet |
REDACT_LOGS |
True |
Replace job IDs and instance CRNs with short hashes in the public Actions log |
REDACT_LOGS defaults on deliberately, this repo can be public, which means its Actions run logs are readable by anyone, and the full IDs are already sitting safely in your private notification either way. There's no cost to leaving it on.
The tracking tag itself is deliberately not configurable. Qoffee encodes a reported failure as
qoffee@F:<epoch>, which needs 13 characters on top of the tag, and IBM caps tags at 24. A longer tag cannot be encoded, so the failure state silently fails to persist and the job is re-reported on every run. Usename:labels to distinguish your jobs instead.
The workflow runs on:
schedule: every 15 minutes by default, automatically. (Not consistent, and has delays, solution discussed below, see the Cloudflare section.)workflow_dispatch: manually, including from the GitHub Mobile app, so you can check on demand without opening a laptop. Manual triggering spins up with no delays.
GitHub's cron scheduler is known to occasionally delay scheduled runs during periods of heavy load. Even if you set up cron for 15 minutes, it will spin up in the range of every 30 to 40 minutes or even more depending on the time of the day, which is acceptable to some people, however, if you demand a more consistent high-frequency scheduler, you can let Cloudflare Worker wake up your GitHub workflow instead. It is easy to set up and will cost you nothing as well.
Cloudflare Worker's sole responsibility is to wake the GitHub workflow on a schedule; all Qoffee logic continues to execute entirely within GitHub Actions, keeping scheduling cleanly separated from monitoring and notification logic. This separation also allows the scheduler to be replaced or removed at any time without requiring any changes to Qoffee itself.
- Sign in to Cloudflare.
- Navigate to Workers & Pages.
- Create a new Worker.
- Replace the default code with the Worker below.
- Deploy the Worker.
Under Settings → Variables and Secrets, create the following secrets.
| Secret | Value |
|---|---|
GITHUB_REPO |
your-username/your-repository |
GITHUB_TOKEN |
A GitHub Fine-Grained Personal Access Token with Actions: Read & Write permission for your Qoffee repository. |
export default {
async scheduled(event, env, ctx) {
console.log("☕ Qoffee scheduler triggered");
ctx.waitUntil(triggerWorkflow(env));
},
async fetch(request, env, ctx) {
return new Response("☕ Qoffee scheduler alive");
}
};
async function triggerWorkflow(env) {
const url =
`https://api.github.com/repos/${env.GITHUB_REPO}/actions/workflows/watch.yml/dispatches`;
console.log("Dispatch URL:", url);
const response = await fetch(url, {
method: "POST",
headers: {
"Authorization": `Bearer ${env.GITHUB_TOKEN}`,
"Accept": "application/vnd.github+json",
"User-Agent": "qoffee-cloudflare-scheduler"
},
body: JSON.stringify({
ref: "main"
})
});
const text = await response.text();
console.log("GitHub status:", response.status);
console.log("GitHub response:", text);
if (!response.ok) {
console.log("✗ GitHub workflow dispatch failed");
} else {
console.log("✓ GitHub workflow dispatched");
}
}From the Worker dashboard:
Settings → Triggers → Cron Triggers
Create a cron schedule such as:
*/5 * * * *
This runs the scheduler every five minutes.
Cloudflare cron expressions use UTC time and changes may take several minutes to propagate globally after being saved.
Create a Fine-Grained Personal Access Token with access only to your selected Qoffee repository.
Required repository permissions:
- Actions: Read and Write
- Metadata: Read (automatically included)
Copy the token into the Cloudflare GITHUB_TOKEN secret.
After deployment, you should see logs similar to:
☕ Qoffee scheduler triggered
Dispatch URL: https://api.github.com/repos/your-name/your-repo/actions/workflows/watch.yml/dispatches
GitHub status: 204
GitHub response:
✓ GitHub workflow dispatched
An HTTP 204 No Content response is the expected success response from GitHub when a workflow dispatch request is accepted.
Open your repository's Actions tab.
Every time the Cloudflare cron fires, a new Qoffee Watcher workflow run should appear.
And that's it. You are done.
- More channels: Telegram, email, Teams, generic webhooks. One file each; the core doesn't change.
- More providers: the seam is built and contract-tested. A future adapter inherits the whole test suite.
- AWS Braket was investigated: Braket cannot filter tasks by tag server-side, and AWS already ships first-party push notifications via EventBridge and SNS. Duplicating a native feature didn't seem like it would be worth the maintenance for now. If you want Braket notifications today, use EventBridge.
-
Do I need to keep my laptop on? No. It runs on GitHub's infrastructure.
-
Does this cost anything? No. GitHub Actions is free for public repos; Discord webhooks are free. The optional Cloudflare Worker is also free-tier.
-
Can you see my quantum jobs? No. There is no server to send them to. Read the code, it's fully public.
-
Can you see my secret keys? No. They are saved in your forked repository settings that belongs to your Github Account.
-
What if I already have jobs tagged from an older version? They migrate automatically.
-
How do I stop tracking a job? Remove the
qoffeetag. Qoffee stops seeing it on the next run. -
What if my notification service is down? Nothing gets untagged, the run goes red, and everything is reported again next run. By design.
Apache License 2.0.
Submit. Tag. Forget.