qloops
v0.1.2
Published
Run a QFactory loop from a YAML manifest. No database, no server, no build step.
Readme
qloop
Describe a loop in a file. Run it with one command.
No database, no server, no build step, no dependencies.
Run one
npm install -g qloopsThe package is qloops; the command it installs is qloop. npm refused the
bare qloop as too close to q-loop, an unrelated package last published in
2016 — so the registry name carries the s and what you type does not.
qloop init release-watch
qloop run release-watch.yamlRelease watch — what shipped in your dependencies · run 819fb995
✓ fetch Fetch releases
✓ llm-call Write the note
✓ api-request Send the note
SUCCESS: Completed 3/3 steps.That is the whole tool. qloop run executes one pass and exits — see
Scheduling for making it happen every morning.
Commands
| | |
|---|---|
| qloop catalog | the loops that ship with this build |
| qloop init <id> [dir] | copy one here, ready to edit |
| qloop validate <manifest> | check it and print what it would do |
| qloop run <manifest> | execute one pass |
| qloop run <manifest> --dry-run | resolve and order every step, touch nothing |
| qloop status [<manifest>] | recent runs, and why the last one stopped |
| qloop approve <manifest> [runId] | continue a run held at a human gate (--reject refuses) |
| qloop doctor | check this machine before blaming the loop |
Exit codes: 0 success · 1 failed · 2 waiting on a human · 64 bad usage.
They differ on purpose — whatever is watching has to tell "it broke" from "there
was nothing today", and both from "somebody needs to look at this".
Installed as qloop. There is deliberately no qf alias: that name belongs to
@q-factory/bridge, and claiming it made npm i -g qloops fail outright — not
partially — for anyone who already had Bridge installed.
Setup
One variable covers most of the catalogue:
export OPENROUTER_API_KEY=sk-or-…Loops that send somewhere need a receiver — a webhook you own, or a Telegram bot:
export QLOOP_WEBHOOK_URL=https://…
export TELEGRAM_BOT_TOKEN=… # from @BotFather
export TELEGRAM_CHAT_ID=…Without a receiver the loop still runs. The result goes to .qf/out/ and
the run says so, loudly — which is how you prove a loop end to end before you
have credentials for anything. Without a model key it is the other way round:
the step fails rather than inventing text, because invented text would
travel down the chain and out through the next request as if it were real.
qloop doctor tells you which of these is set. It prints names, never values.
Templates
Six loops ship with qloop. Each has actually been executed — what
qloop catalog reports is measured from a real run recorded in examples/.
release-watch — what shipped in your dependencies
The cheapest one to try: one credential, no accounts anywhere.
qloop init release-watchChange the URL to a project you actually depend on — GitHub publishes a releases feed for every public repository:
url: "https://github.com/YOUR/REPO/releases.atom"content-feed — the morning digest
Reads a feed, writes a short digest, sends it. Runs to the end on its own.
qloop init content-feedThe message carries a signature line with the real cost of that run —
{{run.costUsd}} resolves to what the run has spent by the time the message
goes out. Delete the line if you do not want it.
brand-mentions — who talked about you today
Searches a news feed and keeps only the mentions actually about you. Says "No mentions today." when there are none, instead of padding.
Point the search at yourself:
url: "https://news.google.com/rss/search?q=YOUR+BRAND&hl=en-US&gl=US&ceid=US:en"feed-fanout — a lane per entry
The alternative to one blended summary: fan-out opens a lane per item, so each
entry gets its own model call and its own outgoing message.
Cost scales with items — five lanes is five model calls. That is what the budget knob is for; it cuts before the call that would cross the ceiling.
maxItems: "5" # raise this and raise budgetUsd with itprice-watch — machine filters, human authorises
The shape to copy whenever a loop ends in something you cannot undo:
fetch → agent-gate → human-gate → api-requestThe agent-gate is a machine check against your rubric — it decides whether
this is worth anyone's attention. The human gate sits in front of the
irreversible step. Run it and it stops:
⏸ approval-gate Send it?
WAITING_HUMAN: Waiting on a human at step "Send it?".qloop approve price-watch.yaml # or --rejectWrite the rubric as a condition, not a wish. A vague rubric passes everything and is worse than no gate at all.
content-factory — draft, check, approve, publish
The same two gates, with publishing at the end. The irreversible step is last and behind both, because a re-run publishes again — there is no idempotency key in the format, and pretending otherwise would be the expensive kind of wrong.
Write your own
A loop is a YAML file with steps. Six kinds, no more: schedule (a trigger),
fetch, llm-call, api-request, approval-gate, fan-out.
manifest: qf.loop/v1
id: hello
steps:
- id: pull
kind: fetch
config: { url: "https://news.ycombinator.com/rss", format: rss, limit: "5" }
- id: write
kind: llm-call
config:
name: "Write digest"
instructions: "One line per entry: title and link. No preamble."
- id: send
kind: api-request
config:
url: "https://example.com/hook"
body: '{"text":"{{steps.Write digest.output.text}}"}'qloop validate hello.yaml && qloop run hello.yaml --dry-runThe full schema — every field of every step kind, the three knobs, what is reserved for later — is in SPEC-MANIFEST.md.
Secrets never go in the file: {{env.NAME}} reads them from the environment,
so sharing a loop does not mean sharing your bot.
State
Everything a run leaves behind sits in .qf/, beside the manifest:
.qf/runs/<runId>.json every step, its output, its tokens, its cost
.qf/last-run.json the newest outcome — status, reason, failing step
.qf/out/<runId>.txt the file sink, when no receiver is configuredPlain files, because the question you actually ask at 09:35 is "what happened",
and cat .qf/last-run.json answers it without a client or a server.
Scheduling
qloop run performs ONE pass. Repetition is launchd's job, where you can see it
in launchctl list and stop it with one command. The template runs daily at
09:30 local time.
sed -e "s#__NODE__#$(command -v node)#" -e "s#__QF__#$(command -v qloop)#" \
-e "s#__MANIFEST__#$PWD/content-feed.yaml#" -e "s#__LOGDIR__#$HOME/Library/Logs#" \
launchd/co.qfactory.content-feed.plist.template > ~/Library/LaunchAgents/co.qfactory.content-feed.plistlaunchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/co.qfactory.content-feed.plistStop it with launchctl bootout gui/$(id -u)/co.qfactory.content-feed.
Secrets come from ~/.qf/env, which the job sources before every run — one file
you can chmod 600, never the plist, never the manifest.
If the machine is asleep at 09:30, launchd runs the job on wake. That is the honest limit of a laptop. A digest that must arrive at 09:30 sharp belongs on something that stays awake.
When something does not arrive
qloop status content-feed.yaml ✗ 2026-08-01 14:53:14 failed "Fetch feed": GET https://… → HTTP 502.
last run FAILED: "Fetch feed": GET https://… → HTTP 502.A failed run exits non-zero and writes the reason down, so "it broke" and "nothing happened today" never look the same.
What is free, and what is not
Running a loop on your machine is free and always will be — that is this repository. Scheduling as a service, quotas, plans, shared workspaces and the run-history UI are the product.
The line: local execution free, orchestration paid.
What this does not do
Stated plainly, because a tool that implies more than it does costs more than one that admits its edges.
- No scheduler.
qloop runis one pass. See Scheduling. - No memory between runs. No cursor, no "last seen". A feed loop re-sends whatever the feed holds now; de-duplication belongs to the receiver.
- No retries, no idempotency key. A re-run repeats the outgoing requests.
- One failed step stops the run. There is no
continue_on_error. - No
sensitivitypolicy. A manifest that sets one is refused, not run with the knob ignored — the profile exists to hold back irreversible steps. - An agent-gate is an LLM judging against your rubric, not a real checker.
mode: checkis reserved in the format and refused, rather than faked. - No
agent-call. Reserved in the format, not implemented.
Keeping it honest
The same manifests also run inside the product, from a database. Two implementations of one behaviour drift, so they are pinned together by a parity test: one manifest, one loader, executed both ways, step sequence and outcomes compared. A difference is a red test, not a footnote.
License
MIT.
