Skip to main content

Automations

Send task events to your own tools as signed webhooks or commands, and read or start tasks from a script.

Your first webhook in 2 minutes​

  1. Save a receiver

    Save this as receiver.mjs:

    receiver.mjs
    import { createHmac, timingSafeEqual } from "node:crypto"
    import { createServer } from "node:http"

    const secret = process.env.QL_SECRET ?? ""
    createServer((req, res) => {
    let body = ""
    req.on("data", (chunk) => (body += chunk))
    req.on("end", () => {
    const [t, v1] = (req.headers["qualitylayer-signature"] ?? "")
    .split(",")
    .map((part) => part.slice(part.indexOf("=") + 1))
    const expected = createHmac("sha256", secret).update(`${t}.${body}`).digest("hex")
    const ok = v1?.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
    console.log(ok ? "verified" : "bad signature", JSON.parse(body).event)
    res.writeHead(ok ? 200 : 401).end()
    })
    }).listen(8787)
  2. Add it

    qualitylayer automations add --url http://127.0.0.1:8787/hook. The reply shows the automation's id and its signing secret.
  3. Start the receiver

    QL_SECRET=<secret> node receiver.mjs, with that secret.
  4. Send a test event

    qualitylayer automations test <id>. The receiver prints verified automation.test.

From now on every task event reaches it. In the App, the same steps are Settings › Automations › Add, then the send button on its row. Your agent can set it up too: "Send QualityLayer events to https://example.com/hook."

Settings › Automations with one webhook, its events and its last deliverySettings › Automations with one webhook, its events and its last delivery

Manage automations​

CommandWhat it does
automations add --url <url>Send events to a URL as signed JSON
automations add --command "<command>"Run a command with each event as JSON on its input
automations listYour automations, with each one's last delivery
automations set <id> --offTurn one off or on, or change its target, --events or --projects
automations test <id>Send a sample automation.test event now and print the result
automations log [<id>]The last deliveries, newest first, and why one failed
automations secret <id>A URL's signing secret
automations eventsThe event names
automations remove <id>Remove one and drop what waits for it

Every command takes --json. --events task.shipped,task.merged sends only some events; --projects shop sends only some projects' tasks. Both default to all.

Events​

Every event has the same envelope. task.step is plan, implement, verify or shipped.

{
"id": "evt_6f1c0a93d27e4b58",
"event": "task.step_changed",
"at": "2026-11-11T09:41:07.120Z",
"version": 1,
"project": { "id": "shop-4f2a91c07b1e", "name": "shop" },
"task": {
"id": "shop-4f2a91c07b1e~2026-11-11-csv-export",
"slug": "2026-11-11-csv-export",
"title": "CSV export",
"type": "feature",
"step": "implement",
"link": "http://127.0.0.1:41888/#/task/shop-4f2a91c07b1e~2026-11-11-csv-export"
},
"data": { "from": "plan", "to": "implement" }
}
EventWhendata
task.createdA task starts{}
task.step_changedA task moves to another step{"from": "plan", "to": "implement"}
question.askedThe agent asks you something{"count": 1, "questions": ["Which export format?"]}
question.answeredYou answer{"question": "Which export format?"}
approval.requestedThe Plan, or the change after Verify's checks, waits for your approval{"for": "plan"} or {"for": "verify"}
approval.decidedYou approve or ask for changes{"for": "verify", "decision": "approved"}, or "changes"
task.verifiedVerify's checks pass and the change waits for your review{"round": 1}, the checking round that passed
task.shippedYou approve the finished change{"slices": 2}
task.mergedIts pull request is merged{"url": "https://github.com/acme/shop/pull/42", "number": 42}
task.archivedA task is archived{}

From start to shipped, a task sends these events in order. Question events come whenever the agent asks.

task.created
approval.requested {"for": "plan"}
approval.decided {"for": "plan", "decision": "approved"}
task.step_changed {"from": "plan", "to": "implement"}
task.step_changed {"from": "implement", "to": "verify"}
task.verified {"round": 1}
approval.requested {"for": "verify"}
approval.decided {"for": "verify", "decision": "approved"}
task.step_changed {"from": "verify", "to": "shipped"}
task.shipped {"slices": 2}

link opens the task in the App on the computer that sent the event. A test send has the event automation.test and a sample task.

Signature​

A URL receives a POST with these headers:

HeaderValue
QualityLayer-EventThe event's name
QualityLayer-DeliveryThe event's id, the same on every retry
QualityLayer-Signaturet=<unix seconds>,v1=<hex>

v1 is the HMAC-SHA256 of <t>.<body> with the automation's signing secret, as in the receiver above. t is the time of that try, so you can refuse old ones. Compare signatures in constant time, and drop an event whose id you already have.

Delivery and retries​

DeliveredA 2xx answer within 10 seconds. Redirects are not followed.
RetriedAfter 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. Every try sends the same body and id.
In orderOne task's events arrive in order: a later one waits while an earlier one is retried. Other tasks go on.
Never in the wayA failing automation never holds a task. Settings › Automations and automations log show what failed.

QualityLayer keeps running in the background until a failed delivery's tries are done, and they go on after sleep or a restart.

Note
Events are sent from the computer that runs the task; nothing passes through qualitylayer.dev. An event carries the task's title and, for a question, its text, never your answer.

Run a command​

#!/bin/sh
# ./scripts/on-event.sh: close the ticket when a task ships
event=$(cat)
[ "$QUALITYLAYER_EVENT" = "task.shipped" ] || exit 0
./scripts/close-ticket.sh "$(echo "$event" | jq -r .task.title)"

qualitylayer automations add --command "./scripts/on-event.sh" runs it once per event, in the task's project folder, with the event's name in QUALITYLAYER_EVENT. It has 60 seconds. Exit 0 is delivered; a command is never run twice, and its exit code and last output are kept in the log.

Read and start tasks from a script​

CommandReturns
tasks --jsonThe project's tasks with id, title, step and dates
status --task <task> --jsonOne task's step, progress and what it waits for
tasks --cost --since 30d --jsonEach task of the period with its step, cost, active time and tokens
task start "<request>" --jsonA new task that waits on Home, with its id
task start --from <link> --jsonThe same, from a GitHub or GitLab issue or pull request, a Jira issue or a Confluence page
task start "<request>" --plan [--agent <id>]The same, and planning starts with your default agent, or the one --agent names
task start "<request>" --planners codex:gpt-6.1-sol:mediumThe same for a feature, with up to two extra planners (agent:model[:effort], comma-separated) that review its Plan instead of the second opinion. Each is an extra paid planning run

Run them in the project's folder. --plan starts an agent on your plan with every call. tasks --cost answers within 20 seconds; when it says "complete": false, run it again for the rest.

No event and none of these commands approves a Plan or a finished change, or answers a question.