Human Approval for Scheduled AI Scripts
Gate the output of a scheduled AI script — a nightly GitHub Actions job, a Lambda cron, a CI-triggered generator — without holding a runner idle for hours waiting on a decision.
The constraint a long-running agent doesn't have
A cron job on a server you own can afford to sit in a polling loop for an hour. A CI-scheduled script usually can't, or shouldn't. GitHub Actions bills by the minute, most self-hosted runners are shared, and a job that blocks for hours on GET /v1/actions/:id is a bad use of a CI runner even when it's technically allowed. This shows up constantly with small automation scripts that use an LLM for one step: a nightly job that drafts release notes from the git log and opens a PR, a scheduled script that summarizes overnight support tickets and posts to Slack, a workflow that generates a changelog and publishes it.
The fix isn't a different approval flow — it's splitting the script into two separate scheduled runs instead of one long-blocking one.
Split into a propose workflow and an execute workflow
The propose workflow does the LLM generation, pushes the action to Impri, and exits immediately — no polling, no waiting.
# .github/workflows/nightly-release-notes-propose.yml
name: Propose nightly release notes
on:
schedule:
- cron: "0 6 * * *" # 06:00 UTC daily
jobs:
propose:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Generate and push for approval
env:
IMPRI_API_KEY: ${{ secrets.IMPRI_API_KEY }}
run: node scripts/propose-release-notes.mjsThe execute workflow runs on its own short schedule, checks for any actions that have since been approved, and only then runs the actual side effect (publishing, posting, committing):
# .github/workflows/execute-approved-actions.yml
name: Execute approved actions
on:
schedule:
- cron: "*/15 * * * *" # check every 15 minutes
jobs:
execute:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- name: Execute anything approved
env:
IMPRI_API_KEY: ${{ secrets.IMPRI_API_KEY }}
run: node scripts/execute-approved.mjsNeither workflow ever blocks. The propose job stores the action ID somewhere durable (a repo file, a small key-value store, a GitHub Actions artifact) so the execute job knows what to look up:
// scripts/propose-release-notes.mjs
import { writeFile } from "node:fs/promises";
const notes = await generateReleaseNotes(); // your LLM call
const res = await fetch("https://api.impri.dev/v1/actions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMPRI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
kind: "changelog.publish",
title: `Release notes — ${new Date().toISOString().slice(0, 10)}`,
preview: { format: "markdown", body: notes },
expires_in: 172800, // 48h — survives a weekend before the check job sees it
editable: ["preview.body"],
}),
});
const { id } = await res.json();
await writeFile(".impri-pending/release-notes.json", JSON.stringify({ id }));The execute job reads that file, calls GET /v1/actions/:id once, and either runs the publish step (if approved) or does nothing and leaves the pending file alone (if still pending).
Secrets and scopes
Store IMPRI_API_KEY as a GitHub Actions repository secret, never in the workflow file itself. Scope it to actions only — a CI script proposing and checking actions has no reason to hold admin or watch scope. If the propose and execute workflows run as separate jobs, they can share the same actions-scoped key; there's no benefit to splitting it further.
Picking expires_in for a schedule, not a person
For a synchronous approval you'd set a short expiry — minutes to hours. For a scheduled script, size the expiry to the gap between runs plus slack for a weekend or a day someone doesn't check their phone:
| Script cadence | Suggested expires_in |
|---|---|
| Runs hourly, reviewed same-day | 21600 (6h) |
| Runs nightly, reviewed next morning | 86400 (24h) |
| Runs nightly, reviewer may miss a weekend | 259200 (72h, the default) |
| Runs weekly | 604800 (7d) |
Too short and a real Friday-evening backlog silently expires before Monday; too long and stale, no-longer-relevant drafts sit around waiting for a stray approval. Treat expired the same as rejected in the execute job — log it and let the next scheduled run generate a fresh proposal if the task still matters.
What this doesn't replace
This pattern is not a substitute for GitHub's own Environments protection rules if you're already using them for deploy gating inside a single workflow run — use those when the approval and the job are the same CI run. Reach for the propose/execute split specifically when the script runs unattended on a schedule and you don't want a runner burning minutes (or a self-hosted runner sitting blocked) waiting on a human. And as always, Impri only holds the decision — it doesn't generate the release notes, doesn't know what "good" output looks like, and the execute script is what actually has to enforce that nothing runs without status: "approved".
For the single-workflow blocking version of this pattern, see gating a cron job. For everything else on wiring up the three calls, start with the quickstart or browse integrations.