OpenLoops is a local CLI and daemon for persistent loops and workflows: scheduled or recurring work that survives process restarts and records every run.
Naming: the product is OpenLoops, published on npm as
@hasna/loops and developed in the
hasna/loops repository. The installed
binaries are loops, loops-daemon, loops-api, loops-serve,
loops-runner, and loops-mcp.
OpenLoops is the runtime, scheduler, and workflow engine for automations —
not the automation domain model. Specs, triggers, queue ownership, approvals,
DLQ/replay, and audit live in @hasna/automations and @hasna/actions; see
Runtime Boundary for ownership split and external
compiler handoff examples.
It supports deterministic command loops, JSON-defined workflows, and guarded CLI adapters for headless coding agents:
claudeagent(Cursor Agent CLI)codewith execaicopilot runopencode runcodex exec
OpenLoops has three deployment modes:
local: SQLite inLOOPS_DATA_DIRis authoritative andloops-daemonexecutes scheduled work.self_hosted: the Hasna-owned AWS/RDS control-plane deployment, served byloops-serveand backed by Postgres, with the embeddableloops-apicontract shared by serve, SDK, and tests. This release exposes status, storage-backed/v1loop CRUD and run listing, runner claim/heartbeat/finalize protocol endpoints, a one-shotloops-runner run-onceexecution path for embedded control-plane hosts, id-preserving local export/import, tenant-bound API-key authentication, and forced Postgres row-level tenant isolation.cloud: a hosted control-plane contract using the same tenant-bound API.
local is the default and requires no network, token, Postgres, or hosted
service. Set HASNA_LOOPS_STORAGE_MODE to local, self_hosted, or
cloud to choose explicitly. Without an explicit mode,
HASNA_LOOPS_API_URL or HASNA_LOOPS_DATABASE_URL selects self_hosted.
The public @hasna/loops package owns the local runtime, mode resolver,
self-hosted API contract, tenant authentication and authorization, runner
contract, SDK, MCP server, and CLI. Account provisioning and infrastructure
deployment remain operator responsibilities. Cloud clients use
HASNA_LOOPS_API_URL plus HASNA_LOOPS_API_KEY.
Scheduler state is explicit in status JSON. schedulerState.localStore is
SQLite plus local run artifact files: authoritative in local, cache/spool in
non-local modes. schedulerState.remoteStore names the non-local contract
(api_control_plane_contract, postgres_contract, or
hosted_control_plane_contract). The standalone loops CLI reports
applySupported=false for non-local apply because it does not perform
id-preserving remote migration, S3/object storage mutation, AWS resource
mutation, or hosted credential mutation. loops-serve mutates self-hosted
Postgres for normal control-plane CRUD and runner protocol routes when it is
explicitly configured. Route admission remains bounded by max_dispatch,
max_active, max_active_per_project, max_active_per_project_group,
max_active_scope, and max_per_profile.
Useful status and setup commands:
loops mode
loops --json mode
loops self-hosted status
loops self-hosted migrate --dry-run
loops self-hosted push --dry-run
loops self-hosted pull --dry-run
loops export --file ./loops-export.json --dry-run
loops export --file ./loops-export.json
loops import ./loops-export.json
loops import ./loops-export.json --apply
loops cloud status
loops-api status
loops-serve version
HASNA_LOOPS_MIGRATOR_DATABASE_URL=... loops-serve migrate --dry-run
loops-serve db-credentials reconcile
HASNA_LOOPS_DATABASE_URL=... HASNA_LOOPS_AUTH_DATABASE_URL=... loops-serve serve
loops-runner statusloops self-hosted push is safe by default: imported workflows are archived and
imported loops are paused with nextRunAt/retryScheduledFor cleared. Even
without --replace, the control-plane import boundary may rewrite existing
same-id workflows/loops into that disabled representation; use explicit
preserve flags only for an intentional activation.
See Deployment Modes for the full package boundary and machine-placement contract.
loops-serve db-credentials reconcile is the fixed in-cluster reconciler for
provider-managed RDS app credentials. It reads the RDS-managed master secret
with the AWS Secrets Manager SDK, builds sslmode=verify-full DSNs only in
memory, and writes each app DSN to that app's Secrets Manager secret through
AWSPENDING before promoting AWSCURRENT. It never accepts database URLs,
passwords, access keys, profiles, or secret material as command flags.
Required environment is intentionally strict: AWS_REGION,
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI, HASNA_LOOPS_RDS_MASTER_SECRET_ARN,
HASNA_LOOPS_MIGRATOR_DATABASE_URL_SECRET_ARN,
HASNA_LOOPS_RUNTIME_DATABASE_URL_SECRET_ARN,
HASNA_LOOPS_AUTH_DATABASE_URL_SECRET_ARN, HASNA_LOOPS_RDS_INSTANCE_ID,
HASNA_LOOPS_RDS_ENDPOINT, HASNA_LOOPS_RDS_PORT,
HASNA_LOOPS_RDS_DATABASE, and HASNA_LOOPS_RDS_MASTER_USERNAME. Static AWS
credential environment variables, profile/web-identity inputs, full credential
URIs, non-RDS-style endpoints, duplicate secret ARNs, cross-region secret ARNs,
and malformed identifiers fail closed before any secret or database operation.
The command normalizes the fixed NOLOGIN database roles
open_loops_owner, open_loops_migrator, open_loops_runtime, and
open_loops_authenticator; manages exactly three LOGIN users
open_loops_migrator_login, open_loops_runtime_login, and
open_loops_authenticator_login; grants only the migrator login exact
owner/migrator membership plus CREATEROLE; and keeps runtime/authenticator
without memberships until migration 0010_tenant_enforce has landed. After
0010, runtime and authenticator are attached only to their matching roles.
If PostgreSQL 16 implicit creator memberships cannot be normalized, the
reconciler fails before publishing app credentials.
OpenLoops requires the Bun runtime (bun >= 1.0). The
loops, loops-daemon, loops-api, loops-serve, loops-runner, and
loops-mcp binaries run with a #!/usr/bin/env bun shebang, so Bun must be on
PATH even when the package is installed with npm. Node.js is not a supported
runtime.
From npm (with Bun installed):
npm install -g @hasna/loops
loops --versionUpdate:
npm update -g @hasna/loops
loops daemon stop
loops daemon start
loops daemon statusRestart the daemon on every machine that runs scheduled loops; already-running daemon processes keep using the old package until restarted.
From source:
bun install
bun run build
bun linkThe CLI stores state in ~/.hasna/loops by default. Set LOOPS_DATA_DIR to isolate state for tests or another profile.
OpenLoops ships a stdio MCP server for safe loop and workflow inspection from MCP-capable agents:
loops-mcp list-tools
loops-mcpThe package also exports the server factory for embedded hosts:
import { createLoopsMcpServer } from "@hasna/loops/mcp";Available read tools include loops_list, loops_show, loops_runs,
loops_doctor, loops_workflows_list, loops_workflow_read, and
loops_workflow_validate.
Resources are available at loops://runtime and loops://tools.
Those tools use the same Store, public redaction helpers, and workflow parser
as the CLI and SDK, so read output and validation behavior stay aligned across
surfaces.
Mutation tools are disabled by default. Start the server with
LOOPS_MCP_ALLOW_MUTATIONS=true only for a trusted local MCP host that should be
allowed to change loop state. The guarded mutation tools use canonical names:
loops_pause, loops_resume, loops_stop, loops_run_now, loops_archive,
loops_unarchive, loops_create_command, and loops_create_workflow.
Deprecated loop_* aliases are still registered where compatibility needs them,
but callers should use the loops_* names. Mutation tools do not require or
accept confirmation-string parameters; the server-side environment opt-in is the
gate. MCP loops_run_now schedules the loop for immediate daemon pickup; inline
execution remains CLI-only.
Keep host-affecting or long-running operations on the CLI: daemon
start/stop/install/logs, inline run-now, tick, loop deletion, workflow
create/migrate/cancel/recover, agent loop creation, template materialization,
and event-route drains.
Run a deterministic command every minute:
loops create command repo-status --every 1m --cmd "git status --short" --cwd /path/to/repoRun a Claude loop every morning:
loops create agent morning-check \
--provider claude \
--cron "0 8 * * *" \
--cwd /path/to/repo \
--prompt "Check whether this repo is healthy and summarize required action."Run a Claude loop with an isolated OpenAccounts profile:
loops create agent morning-check \
--provider claude \
--account work \
--account-tool claude \
--cron "0 8 * * *" \
--cwd /path/to/repo \
--prompt "Check whether this repo is healthy and summarize required action."Run a Codewith loop every 15 minutes:
loops create agent supply-chain-watch \
--provider codewith \
--every 15m \
--cwd /path/to/repo \
--sandbox workspace-write \
--prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."Run a Codewith loop with a Codewith-native auth profile:
loops create agent supply-chain-watch \
--provider codewith \
--auth-profile account001 \
--every 15m \
--cwd /path/to/repo \
--sandbox workspace-write \
--prompt "Check for suspicious dependency or supply-chain changes. Report only concrete findings."Run an OpenCode loop with an explicit provider/model. OpenCode reads
~/.config/opencode/config.json when no model is supplied, so OpenLoops rejects
OpenCode agent targets without --model instead of inheriting a stale or
machine-specific default.
loops create agent opencode-smoke \
--provider opencode \
--model openrouter/google/gemini-2.5-flash \
--at "$(date -u -d '+1 minute' +%Y-%m-%dT%H:%M:%SZ)" \
--cwd /path/to/repo \
--prompt "Reply with exactly OK."For codewith and aicopilot account isolation, register matching OpenAccounts tools first if they are not built in on the machine:
accounts tools add codewith --label "Codewith" --env-var CODEWITH_HOME --bin codewith
accounts tools add aicopilot --label "AI Copilot" --env-var AICOPILOT_CONFIG_DIR --bin aicopilotUse prompt files for production agent prompts instead of long inline strings.
mkdir -p ~/.hasna/loops/prompts
$EDITOR ~/.hasna/loops/prompts/repo-morning-check.md
loops create agent morning-check \
--provider codewith \
--auth-profile account001 \
--cron "0 8 * * *" \
--cwd /path/to/repo \
--prompt-file ~/.hasna/loops/prompts/repo-morning-check.mdWorkflow JSON also supports promptFile on agent targets. Relative paths
resolve from the workflow JSON file's directory:
{
"name": "repo-morning",
"steps": [
{
"id": "review",
"target": {
"type": "agent",
"provider": "codewith",
"cwd": "/path/to/repo",
"promptFile": "prompts/repo-morning-review.md"
}
}
]
}OpenLoops records promptSource metadata and redacts prompt bodies in public
CLI output by default, including templates render. Use
~/.hasna/loops/prompts/<stable-name>.md as the default prompt store for
production loops.
Reusable custom templates cannot contain promptFile. Keep prompt files in
direct workflow JSON or agent loop creation; use template variables only for
non-secret routing/configuration data.
Add --goal to wrap a command, agent, or workflow loop in an AI-SDK orchestration layer. OpenLoops asks the configured model to create a flat DAG plan, then executes ready plan nodes by re-running the underlying target once per node. Each node run is parameterized with that node's objective: agent prompts get the goal and node objective appended, and every wrapped process receives LOOPS_GOAL_NODE_KEY and LOOPS_GOAL_NODE_OBJECTIVE in its environment. An adversarial achievement audit runs before the goal is marked complete.
The goal autoExecute mode is honored: off plans and persists the DAG without executing any nodes (the run reports the persisted plan), while readyOnly (the default) and aiDirected run the dependency-driven execution loop over ready nodes.
export OPENROUTER_API_KEY=...
loops create agent repo-fixer \
--provider codex \
--at "$(date -u -d '+1 minute' +%Y-%m-%dT%H:%M:%SZ)" \
--cwd /path/to/repo \
--prompt "Work only on the requested repository task." \
--goal "Fix the failing lint check and prove it with a passing lint run." \
--goal-budget 2000 \
--goal-model openai/gpt-4o-mini \
--goal-max-turns 5Goal planning and validation use the Vercel AI SDK with @openrouter/ai-sdk-provider. Set OPENROUTER_API_KEY; optionally set LOOPS_GOAL_BASE_URL to point at a local gateway compatible with OpenRouter. Goal context is passed to wrapped commands and agents as LOOPS_GOAL_ID, LOOPS_GOAL_OBJECTIVE, LOOPS_GOAL_NODE_KEY, and LOOPS_GOAL_NODE_OBJECTIVE.
If a resumed goal plan has no runnable nodes, failed run output includes a
structured diagnostics object with the concrete blocker and owner
(goal, goal-plan, or goal-plan-node) so scheduled workflow loops do not
end as opaque blocked plans.
Inspect configured and runtime goal state:
loops goal show <loop-or-goal-id>
loops goal show <goal-run-id-or-loop-run-id>Workflow JSON can also embed goals at the workflow or step level:
{
"name": "goal-workflow",
"goal": { "objective": "Complete the workflow and verify the evidence.", "maxTurns": 3 },
"steps": [
{
"id": "fix",
"goal": { "objective": "Finish this step and prove it with output evidence." },
"target": { "type": "command", "command": "bun test", "shell": true }
}
]
}For workflow loops (loops create workflow ...), prefer a single loop-level
--goal wrapper around the scheduled workflow. Do not also define a top-level
"goal" on the workflow JSON spec: nested loop-level and workflow-level goal
wrappers deadlocked execution because each layer waited on the other. OpenLoops
now rejects new workflow loops that combine both wrappers at creation time, and
blocks retargeting a loop-level goal loop onto a workflow that also carries a
top-level goal.
Step-level goals inside workflow JSON remain valid. When a loop-level goal wraps a workflow that still carries a legacy top-level goal, the runner strips the workflow goal for that execution only; migrate away from the redundant wrapper with the append-only migrator (see Workflows below).
Create a workflow JSON file:
{
"name": "repo-morning",
"steps": [
{
"id": "status",
"target": {
"type": "command",
"command": "git",
"args": ["status", "--short"],
"cwd": "/path/to/repo"
}
},
{
"id": "review",
"dependsOn": ["status"],
"target": {
"type": "agent",
"provider": "codex",
"account": { "profile": "work", "tool": "codex" },
"cwd": "/path/to/repo",
"prompt": "Review the repository status and summarize concrete next actions."
}
}
]
}Save, run, inspect, and schedule it:
loops workflows validate repo-morning.json
loops workflows validate repo-morning.json --preflight
loops workflows create repo-morning.json
loops workflows run repo-morning --show-output
loops workflows runs repo-morning
loops workflows inspect <workflow-run-id>
loops workflows events <workflow-run-id>
loops workflows cancel <workflow-run-id> --reason "no longer needed"
loops workflows recover <workflow-run-id>
loops create workflow repo-morning-loop --workflow repo-morning --cron "0 8 * * *"Use recover only for interrupted running workflow runs whose recorded child
process is gone. Terminal timed_out task/event workflow runs are audit
history; use loops routes requeue <work-item-id> --reason "<cause fixed>"
after fixing the cause, then redeliver or drain the original task/event route.
Workflow specs are stored separately from loops. A loop can schedule a workflow, but workflow runs and step runs have their own durable rows and events. Steps run in dependency order and a scheduled workflow run is idempotent per loop slot.
For command steps, command is the executable when shell is not true. Put flags in args:
{ "type": "command", "command": "git", "args": ["status", "--short"] }Use shell: true only when you intentionally want shell parsing:
{ "type": "command", "command": "git status --short", "shell": true }Built-in templates turn common orchestration flows into reusable workflow JSON.
Todos-task routes are OpenLoops-native runtime admission — see
Runtime Boundary for how this differs from
OpenAutomations queue ownership.
todos-task-worker-verifier performs one todos task and then verifies it.
event-worker-verifier handles any Hasna event envelope and then verifies the
handling. bounded-agent-worker-verifier is for recurring bounded agent work:
one worker runs a narrow objective, then a fresh verifier audits the result.
The catalog also includes task-lifecycle, pr-review, scheduled-audit,
knowledge-refresh, report-only, incident-response, and
deterministic-check-create-task for common operator workflows. Use
routing-remediation for bounded routing-doctor repair runs: it dry-runs by
default, gates safe_auto capacity, applies only supported
todos doctor routing --apply repairs when explicitly enabled, and files
blocker tasks for human/cross-repo/unsupported findings.
loops templates list
loops templates render todos-task-worker-verifier \
--var taskId=<task-id> \
--var taskTitle="Fix parser" \
--var projectPath=/path/to/repo \
--var provider=codewith \
--var authProfilePool=account001,account002,account003 \
--var sandbox=workspace-write \
--var todosProjectPath=$HOME/.hasna/loops \
--var addDirs=$HOME/.hasna/todos,$HOME/.hasna/loops
loops workflows create --template todos-task-worker-verifier \
--var taskId=<task-id> \
--var projectPath=/path/to/repo
loops templates render event-worker-verifier \
--var eventId=<event-id> \
--var eventType=knowledge.record.created \
--var eventSource=knowledge \
--var eventJson='{"id":"<event-id>"}' \
--var projectPath=/path/to/repo
loops templates render bounded-agent-worker-verifier \
--var objective="Check docs drift and queue tasks for gaps" \
--var projectPath=/path/to/repo \
--var provider=codewith \
--var authProfilePool=account001,account002 \
--var sandbox=workspace-write \
--var worktreeMode=required
loops templates render pr-review \
--var prUrl=https://github.com/hasna/loops/pull/123 \
--var projectPath=/path/to/repo
loops templates render deterministic-check-create-task \
--var projectPath=/path/to/repo \
--var checkCommand='your deterministic check and todos upsert command'
loops templates render routing-remediation \
--var projectPath=/path/to/repo \
--var todosProjectPath=/path/to/todos-project \
--var shard=0/6 \
--var maxRepairs=25 \
--var idempotencyKey=routing-health:repo:shard0Custom reusable workflow templates live under the OpenLoops app data directory:
~/.hasna/loops/templates by default, or $LOOPS_DATA_DIR/templates when
LOOPS_DATA_DIR is set. Store templates as declarative JSON files; listing,
showing, and rendering templates never executes workflow steps or mutates the
registry.
Timeout policy is explicit. Deterministic command/check steps should normally
keep finite timeoutMs/idleTimeoutMs guards so broken shell work cannot run
forever. Agentic work steps default to no wall-clock timeout in built-in
worker/verifier and task-lifecycle templates; use timeoutMs: null in workflow
JSON, or --timeout none / --timeout unlimited for CLI-created targets, when
a step may need hours or days. Verifier/evaluator steps add a 15 minute
idleTimeoutMs watchdog by default so a verifier cannot hang silently after the
worker finishes; pass --verifier-idle-timeout none or template variable
verifierIdleTimeoutMs=none only when another heartbeat is guaranteed. Use a
positive numeric timeoutMs only when an agentic step is intentionally bounded.
To migrate existing agentic loops, use the timeout migrator instead of editing
the database directly. Workflow loops are migrated append-only because
historical workflow runs must keep pointing at their original spec; direct
agent loops selected with --loop update their stored target in place for
future executions:
loops workflows migrate-agent-timeouts --loop <loop-id-or-name>
loops workflows migrate-agent-timeouts --loop <loop-id-or-name> --apply
loops workflows migrate-goal-wrappers --loop <loop-id-or-name>
loops workflows migrate-goal-wrappers --loop <loop-id-or-name> --apply
loops workflows migrate-goal-wrappers --loop <loop-id-or-name> --apply --archive-oldBoth migrators dry-run by default. For eligible non-running workflow loops,
--apply creates a new workflow spec and retargets only future executions;
historical workflow runs keep pointing at their original spec. For direct agent
loops selected with --loop, migrate-agent-timeouts --apply updates the
stored target in place for future executions. Use --archive-old to archive
superseded workflow specs when no active loops still reference them.
migrate-agent-timeouts clones the workflow with the requested agent timeout
policy (--timeout none by default). migrate-goal-wrappers targets loops that
define both a loop-level goal and a redundant workflow-level top-level goal: it
clones a goal-free workflow spec, retargets the loop, and leaves the loop-level
goal as the sole orchestration wrapper. Loops with only a workflow-level goal or
only a loop-level goal are skipped.
Use loops workflows recover only for interrupted running workflow runs
whose recorded child process is gone; terminal timed_out runs must be
requeued with loops routes requeue <work-item-id> --reason "<cause fixed>"
before re-delivering or draining the original task/event route.
{
"id": "custom-report",
"name": "Custom Report",
"description": "Run a custom report workflow from the local template registry.",
"kind": "workflow",
"variables": [
{ "name": "objective", "required": true, "description": "Report objective." },
{ "name": "projectPath", "required": true, "description": "Working directory." }
],
"workflow": {
"name": "custom-report-${objective}",
"steps": [
{
"id": "worker",
"target": {
"type": "agent",
"provider": "codewith",
"prompt": "/goal ${objective}\nProduce the requested report only.",
"cwd": "${projectPath}",
"configIsolation": "safe",
"permissionMode": "bypass",
"sandbox": "workspace-write",
"timeoutMs": null
},
"timeoutMs": null
}
]
}
}loops templates validate ./custom-report.json
loops templates import ./custom-report.json
loops templates list --source custom
loops templates show custom-report
loops templates render custom-report \
--var objective="Check docs drift" \
--var projectPath=/path/to/repo
loops workflows create --template custom-report \
--var objective="Check docs drift" \
--var projectPath=/path/to/repoUse --source builtin, --source custom, or --source all on
templates list, templates show, templates render, and
workflows create --template when automation needs an explicit source. Custom
template ids and names cannot override built-ins.
Custom templates fail closed for danger-full-access, dangerous passthrough
arguments, and implicit Codewith/Codex full-access defaults. If a custom
Codewith/Codex template uses permissionMode: "bypass", it must also set
sandbox to workspace-write or read-only. Use built-in templates with
explicit break-glass handling for emergency workflows that need full access.
For event-driven task automation, loops routes create todos-task reads a
Hasna event envelope from stdin or HASNA_EVENT_JSON, records a
WorkflowInvocation, upserts an admission work item, and admits that work item
into a deduped one-shot workflow loop when route capacity allows:
cat task-created-event.json | loops routes create todos-task \
--template task-lifecycle \
--provider codewith \
--auth-profile-pool account001,account002,account003 \
--permission-mode bypass \
--sandbox workspace-write \
--todos-project "$HOME/.hasna/loops" \
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
--worktree-mode requiredTask routing is explicit opt-in. The handler skips the event without creating a
workflow unless the event data or metadata has route_enabled=true,
automation.allowed=true, or a task tag containing auto:route. It also skips
blocked, completed/done, cancelled/canceled, failed, archived, manual,
approval-required, or no-auto tasks. Terminal route work items such as
failed, dead-letter, cancelled, or succeeded history are re-admitted only when
the todos task is still actionable, the per-attempt backoff has elapsed, and the
redispatch cap has not been reached. Operators can still force a retry with
loops routes requeue <work-item-id> --reason "<cause fixed>". The next
route-created output records requeue evidence with the previous work item id,
previous attempts, reason, new attempt, workflow id, and loop id.
Route work items are the durable reservation ledger: they include the stable
idempotency key, task/event references, project/group keys, admitting machine
ID, route scope, workflow/loop IDs, and the current terminal or active status.
Task route drains can select providers from task metadata instead of running one
fixed provider/account pool for the whole drain. Add one or more
--provider-rule field=value:provider[:profile1,profile2] flags; the first
matching rule wins. Rule profiles become a Codewith auth-profile pool for
provider=codewith and an OpenAccounts account pool for other providers. Tasks
can also carry provider_hint/route_provider, auth_profile_pool, or
account_pool metadata. Dry-run, drain evidence, and route invocation scope
include providerRouting so operators can see why a provider/account was
selected. Selector values may contain :; the parser treats the colon before a
supported provider id as the route delimiter, so exact tag selectors such as
tags=area:frontend:claude:account003,account015 and
tags=provider:claude-code:claude:account003,account015 match literal task
tags area:frontend and provider:claude-code.
loops routes drain todos-task \
--dry-run \
--provider-rule tags=area:frontend:claude:account003,account015 \
--provider-rule tags=provider:claude-code:claude:account003,account015 \
--provider-rule area=frontend:claude:claude-ui-a,claude-ui-b \
--provider-rule area=backend:codewith:account001,account002 \
--provider-rule tags=task-lifecycle:codewith:account001,account002 \
--worktree-mode requiredPR approval or merge tasks that need a branch-protection review must carry
explicit non-author GitHub reviewer evidence before a worker is created. When a
task has a PR reference plus reviewDecision=REVIEW_REQUIRED,
mergeStateStatus=BLOCKED, branch-protection review language, or similar
approval/merge intent, routing is skipped unless --github-reviewer,
--github-reviewer-pool, or task metadata such as github_reviewer /
github_reviewer_pool names at least one GitHub login different from the PR
author. This blocks self-review routes before they can spawn an impossible
worker, and dry-run/admission JSON includes prReviewRouting evidence.
By default, todos-task routes use todos-task-worker-verifier for backwards
compatibility. Use --template task-lifecycle to run the full triage ->
planner -> worker -> verifier lifecycle. The route rejects unrelated templates
such as pr-review so a todos task cannot accidentally use the wrong contract.
The default worker/verifier template starts with a deterministic
source-task-gate command that runs todos --project <source-store> --json inspect <task-id> before the worker. If the routed source task cannot be
resolved in the intended Todos store, the workflow fails before repo-mutating
agent work starts.
The lifecycle template adds deterministic gates after triage and planning. If
either step marks the task blocked, omits its contextual
openloops:triage=go task=<id> event=<event-id> /
openloops:planner=go task=<id> event=<event-id> marker comment, or the task
is blocked/completed/done/cancelled/failed/archived/no-auto/manual/
approval-required, the worker step is not started. Use
--triage-auth-profile, --planner-auth-profile, --worker-auth-profile, and
--verifier-auth-profile when exact Codewith profiles are needed, or use
--auth-profile-pool for deterministic role rotation.
For other Hasna apps that expose @hasna/events webhooks, use the generic
handler:
cat event.json | loops routes create generic \
--provider codewith \
--auth-profile-pool account001,account002,account003 \
--permission-mode bypass \
--sandbox workspace-write \
--project-path /path/to/repo \
--worktree-mode requiredThis is the intended deterministic-to-agentic path: a producer creates a todos
task, @hasna/events delivers task.created, OpenLoops records the invocation
and admission item, OpenLoops creates a worker/verifier workflow when admitted,
and the workflow updates todos with evidence. Use account pools so worker and
verifier steps do not burn the same profile. For live Codewith drains, OpenLoops
spreads each route to the least-loaded pool account (counting running steps
per account) and still keeps the verifier on a different profile than the worker;
the deterministic hash is the tie-break, so a cold store reproduces the previous
assignment exactly. --max-per-profile <K> (default 2 for pools of two or more,
0 to disable) defers a route when every pool account already has K running
steps — the guardrail that keeps higher concurrency from stacking on one
subscription and tripping provider-side rate limits. The chosen account per role
is recorded on each step (account_profile) and surfaced in drain reports.
Use --dry-run to inspect the rendered invocation, work item, workflow, and
loop input without storing anything.
Generated worker/verifier workflows fail closed when sandbox=danger-full-access
is requested without manualBreakGlass=true. Use workspace-write for
unattended task/event routes. Full access is an explicit manual emergency path,
not a default automation mode.
When a sandboxed Codewith/Codex worker must update app stores outside the repo
worktree, pass those stores explicitly with --add-dir or template addDirs.
For task-created routes, pass --todos-project so worker/verifier prompts use
the actual todos storage project while repo routing and worktree isolation still
use the repository path. This avoids danger-full-access for normal todos
comments, completion state, and loop evidence writes. addDirs is intentionally
accepted only for Codewith/Codex until other providers expose equivalent
directory-scoped write controls.
Inspect route state with:
loops routes policies list
loops routes policies show oss
loops routes policies render oss
loops routes policies validate
cat task-created-event.json | loops routes preview todos-task --sandbox workspace-write
cat task-created-event.json | loops routes create todos-task --sandbox workspace-write
loops routes drain todos-task --task-list oss --max-dispatch 2 --compact
loops routes schedule todos-task route-drain-oss-5m --every 5m --task-list oss --max-dispatch 1 --compact
loops routes list --route-key todos-task
loops routes show <work-item-id>
loops routes requeue <work-item-id> --reason "fixed upstream blocker"
loops routes invocationsNamed route policies let operators create and inspect recurring task-drain
routes without copying the full live command. Built-in policies are
repoops-pr-queue, oss, pilot, and machine-sync:
loops routes policies render oss
loops routes schedule todos-task machine-oss-task-lifecycle-router --policy ossScheduling with a policy stores explicit route drain args plus
--route-policy-evidence <id> instead of replaying --policy, so audits can see
the exact task list, filters, account pool, throttles, worktree mode, evidence
directory, and name prefix used for future runs. The pilot policy is a manual
break-glass lane using danger-full-access; applying it requires an explicit
--manual-break-glass.
For OSS task-created routing, use a deterministic drain instead of tmux dispatch:
loops routes schedule todos-task oss-task-route-drain \
--every 5m \
--todos-project "$HOME/.hasna/loops" \
--template task-lifecycle \
--project-path-prefix "$HOME/workspace/example/opensource" \
--tags auto:route \
--provider codewith \
--auth-profile-pool account001,account002,account003 \
--add-dir "$HOME/.hasna/todos,$HOME/.hasna/loops" \
--project-group oss \
--max-dispatch 2 \
--scan-limit 5000 \
--max-active-per-project 1 \
--max-active-per-project-group 4 \
--max-active 12 \
--provider-active-cap 6 \
--provider-admission-check \
--max-per-profile 2 \
--launch-gate "pa19-controlled-launch" \
--launch-gate-blocker "$HOME/workspace/example/opensource/open-codewith::2d9d931b" \
--launch-gate-blocker "$HOME/workspace/example/opensource/open-loops::816e99db" \
--worktree-mode required \
--evidence-dir "$HOME/.hasna/loops/reports/oss-task-route-drain" \
--compactUse --launch-gate-blocker <todos-project-path>::<task-id> for staged
launches where a scheduled drain must create zero worker loops until explicit
blocker tasks are completed. While any blocker is still pending, in progress,
failed, cancelled, missing, or unreadable, the drain fails closed before reading
the ready queue, writes launch-gate evidence, and leaves source tasks untouched.
Scheduled route drains preserve these gate flags in their stored argv.
--max-active counts active routed workflows per route. Scope precedence:
an explicit --max-active-scope <key> wins, else the running loop's
LOOPS_LOOP_NAME, else the route key — so each router's limit is its own
ceiling rather than a store-wide total shared by every router.
Compact drain output includes route reservation IDs, work-item status,
machine ID, workflow ID, loop ID, and route scope for each considered task.
--max-active-per-project and --max-active-per-project-group remain cross-route
anti-hog caps counted over all routes. Raise a router's --max-active
deliberately once counting is per-route; keep --max-per-profile set so the
extra concurrency spreads across subscription accounts.
--max-active and related route throttles count OpenLoops routed workflow work
items; they do not know whether Codewith is already at its live background-agent
admission limit. Add --provider-active-cap <n> (or the Codewith-specific alias
--codewith-active-cap <n>) to make the route run codewith agent diagnostics --json before creating workflow loops and defer when activeRunCount >= n.
Add --provider-admission-check when drains should also fail closed on provider
diagnostics failure, unsupported providers, or Codewith reports with no
available admission slots. Backlog prioritizer and drain loops should use these
first-class flags instead of shell-wrapping a temporary diagnostics guard.
Only route tasks that explicitly opt in with auto:route, route_enabled=true,
or automation.allowed=true. Use Codewith account pools, required worktrees,
max-active throttles, and bounded evidence directories. Do not dispatch or paste
task prompts into tmux panes. Keep --scan-limit large enough for the local
ready-task backlog; otherwise low-priority or newly-created tasks can sit beyond
the scanned window. Generated one-shot task/event route workflow specs are
archived automatically when their workflow run reaches a terminal state;
workflow run history and manifests are preserved.
Workflow run manifests are written under
.hasna/loops/runs/<project-slug>/<subject-key>/<run-id>/manifest.json.
subject-key is a safe derived path segment, not the raw subject reference.
OpenLoops executes automations that external compilers have materialized; it
does not own the automation product surface. See
Runtime Boundary for the full ownership table,
three handoff paths (claim-queue, planned @hasna/actions upsert-one-shot, and
explicit event-envelope routing), todos-task route distinction, and
anti-patterns.
Quick reference:
import { openAutomationsRuntimeBinding } from "@hasna/loops";
const binding = openAutomationsRuntimeBinding();
// binding.handoff === "claim-queue"
// binding.eventHandoff.handlerCommand === "loops routes create generic"automations queue claim --runner open-loops:<worker-id>
automations queue complete <action-id> --runner open-loops:<worker-id>For explicit event workflow routing, OpenAutomations can export the normalized event envelope and OpenLoops can consume it through the existing generic event route:
automations --json webhooks event <route> --body-json '<json>' \
| loops --json routes create genericThis is not automation materialization in OpenLoops. It is an explicit
event-envelope workflow handoff: OpenAutomations owns deterministic automation
specs, webhook normalization, queue state, approvals, DLQ, and replay; OpenLoops
owns agent workflow invocation after the operator routes the envelope to
loops routes create generic.
Do not store automation specs in OpenLoops, infer automation triggers from event
transport alone, or replace the OpenAutomations queue with loop/workflow rows.
When a loop or workflow is used for execution, keep HASNA_AUTOMATIONS_DIR
pointing at the owning OpenAutomations data root and preserve the runner id in
completion/failure calls so OpenAutomations can enforce action leases.
External compilers such as @hasna/actions and @hasna/automations should not
write OpenLoops SQLite rows directly. The stable contract should be an
idempotent CLI/SDK upsert that accepts a fully rendered one-shot workflow loop
request and returns durable refs.
The canonical WorkflowUpsertRequest / WorkflowUpsertResult shapes and their
required semantics (dry-run/preflight/commit modes, idempotencyKey +
specHash idempotency, dispatch behavior, and redaction-before-persistence)
are specified in docs/AUTOMATION_RUNTIME_DESIGN.md; this README intentionally
does not duplicate that spec.
That design doc also defines the planned @hasna/actions target binding. The
binding reuses ActionManifest, ActionInvocation, ActionRunStatus,
ActionQueueStatus, action-owned idempotency keys, action audit events, and
dead-letter/replay records. OpenLoops only admits and executes the handed-off
workflow, then returns workflow refs for action-owned evidence.
The same design doc covers the planned DLQ/dead-letter lifecycle, including
loops dlq list/show/replay/resolve, idempotent replay keys, and compatibility
rules for @hasna/actions.
The same design doc also defines the planned strict automation execution mode: minimal env inheritance, scoped secret refs, enforced allowlists, redaction-before-persistence, and provider-safe defaults for automation-created work.
OpenLoops can turn long-form media or meeting transcripts into recurring workflow work when paired with iapp-transcriber. The template at docs/workflows/transcript-feedback-to-loops.json transcribes an authorized media URL, asks an agent to extract recurring loop candidates, authors workflow specs, and validates generated workflows before scheduling. Copy it into the target repo, replace /path/to/repo with that repo's absolute path, and provide TRANSCRIBER_SOURCE_URL through the runner environment or a private, uncommitted workflow copy before storing or scheduling it. Do not commit private or signed media URLs.
loops workflows validate /path/to/repo/.openloops/transcript-feedback-to-loops.json --preflight
loops workflows create /path/to/repo/.openloops/transcript-feedback-to-loops.json
loops workflows run transcript-feedback-to-loops --show-outputSee docs/TRANSCRIPT_LOOP_PATTERNS.md for transcript-to-loop guardrails and example schedules for review, maintenance, CI optimization, feedback triage, and knowledge-capture loops.
loops list
loops show <id-or-name>
loops runs <id-or-name>
loops pause <id-or-name>
loops resume <id-or-name>
loops stop <id-or-name>
loops rename <id-or-name> <new-name>
loops archive <id-or-name>
loops unarchive <id-or-name>
loops remove <id-or-name>
loops run-now <id-or-name>Use --json for machine-readable output. Prompt bodies and run stdout/stderr are redacted by default in status output. loops run-now exits non-zero when the recorded run fails or times out.
loops health --json
loops health scan --include active,paused --latest-run --doctor --daemon --json
loops health scan --include active,paused --latest-run --doctor --daemon \
--upsert-todos --dry-run --max-actions 5 --evidence-dir ~/.hasna/loops/reports/health-scan
loops expectations <loop-id-or-name> --json
loops health route-tasks --project ~/.hasna/loops --task-list loop-error-self-heal --max-actions 5
loops hygiene names --json
loops hygiene duplicates --json
loops hygiene scripts --json
loops hygiene route-tasks --checks duplicates,scripts --project ~/.hasna/loops --task-list openloops-hygienehealth and expectations classify latest-run failures with stable
fingerprints and bounded evidence. health route-tasks is the explicit
mutating path: it upserts deduped Todos tasks for failed expectations and marks
them with no_tmux_dispatch=true metadata. Use --dry-run --json before
turning it into a production loop.
health scan is the first-class replacement for local loop-error self-heal
scripts. It inventories active/paused loops by default, can include doctor,
daemon, preflight, latest-run, and stale-running findings, writes bounded
summary.json and report.md files under
$LOOPS_DATA_DIR/reports/health-scan, and prints compact human or stable JSON
output. It is read-only unless --upsert-todos is supplied. The only built-in
self-heal is --start-daemon, which attempts to start the daemon when status
proves it is not running; it never stops, resumes, deletes, archives, or reaps
loops.
Add --evidence-dir <dir> to health route-tasks or hygiene route-tasks
when the deterministic loop should write a durable JSON heartbeat/report in
addition to loop stdout. Add --auto-route only for task lists that are
intentionally connected to task-created worker/verifier automation; it appends
auto:route, sets route metadata, and lets OpenTodos/OpenEvents trigger the
existing headless workflow path when the finding has a cwd or
--route-project-path is provided. Findings with no routeable working
directory stay as tasks and record an auto_route_skipped_reason. Without
--auto-route, the command only creates or updates deduped tasks.
hygiene names reports canonical machine-* or repo-<name>-* loop names and
renames only with --apply. Cadence/timer suffixes such as -5m, -15m,
-6h, -hourly, and -daily are removed from canonical names; cadence belongs
in the schedule metadata and the human loops list cadence= column. Apply
mode writes a SQLite backup under <LOOPS_DATA_DIR>/backups before changing loop
names. New loops get a compact default description with Why/How/Outcome text
unless the operator supplies --description. hygiene duplicates groups loops
with the same normalized name, cwd, and schedule. hygiene scripts inventories
loops whose command still references ~/.hasna/loops/scripts.
hygiene route-tasks upserts deduped Todos tasks for hygiene findings with
stable fingerprints and no_tmux_dispatch=true metadata. Route commands use a
package-managed cursor under <LOOPS_DATA_DIR>/route-cursors.json so bounded
--max-actions runs advance through all findings over repeated scheduled runs
instead of reprocessing only the first batch.
Use loops rename for deliberate operator naming changes that should not be
encoded as automatic hygiene policy:
loops rename machine-todos-drain-oss-repos-strict-5m machine-todos-drain-oss-repos
loops --json rename <loop-id> machine-ops-loop-health-route-tasksrename preserves the loop id, schedule, run history, archive state, and target
configuration. It rejects empty or duplicate names and writes a SQLite backup
under <LOOPS_DATA_DIR>/backups before mutating the loop row. Keep cadence in
schedule metadata and operator tables rather than in the loop name when the name
is meant for humans.
Archive loops when retiring old automation but preserving history:
loops archive <id-or-name>
loops list --archived
loops list --allArchived loops are hidden from the default loops list, excluded from daemon scheduling and doctor preflight, and cannot be run manually until restored with loops unarchive. loops remove deletes the loop record; prefer archive for superseded loops that may need audit history.
loops run-now reports the manual run source:
source=ad_hoc: the loop was not due yet, so OpenLoops created a one-off manual slot. This is a single immediate attempt and does not schedule retries or consume the future scheduled slot.source=due_slot: the persisted scheduled slot was already due, so the manual run claims that slot and advances or retries the loop using normal scheduler rules.source=retry_slot: the loop was waiting on a failed slot retry, so the manual run claims that retry slot and advances the loop using normal retry rules.
loops daemon start
loops daemon status
loops daemon logs
loops daemon stop
loops doctorRun in the foreground for supervised environments:
loops daemon runInstall startup integration:
loops daemon install
loops daemon install --enableOn Linux this writes a user systemd service. On macOS it writes a LaunchAgent plist. The command prints the exact enable/load commands to run. --enable runs the user-service enable/start command when supported.
once: one run at an absolute date/time.interval: fixed-rate by default. The next slot is based on the scheduled slot, then advanced past the completion time to avoid hot-looping after downtime.cron: five-field cron expression using the host local timezone.dynamic: one-minute cadence by default and no backfill.catch_up=latestby default: downtime coalesces missed interval/cron slots to the latest eligible slot.catch_up=all: runs up tocatch_up_limitmissed slots.catch_up=none: runs only the persisted next slot.overlap=skipby default: a due slot records a skipped run if a previous run is still active.- Each run is keyed by
(loop_id, scheduled_for)so a slot is claimed once. - Failed slots retry only when
--attemptsis greater than1; retries keep the originalscheduled_forvalue. - Failed ad-hoc manual
run-nowslots are single attempts and do not schedule retries. Due-slot and retry-slot manual runs use normal retry behavior. - Running rows have leases. If a daemon dies, a later daemon marks expired running rows as
abandoned.
The adapters intentionally use provider command surfaces instead of pretending every agent has one SDK:
- Claude uses
claude -p --output-format jsonand safe-mode/local setting sources by default. - Codewith runs non-interactive
codewith --ask-for-approval never exec --jsonsessions by default. exec starts a fresh session per invocation, avoiding the multi-megabyte rollout history thatcodewith agent startreloaded every turn (which drovecontext_length_exceededsilent no-ops), and it keeps network egress for gh/git — theworkspace-writesandbox opts back intosandbox_workspace_write.network_access. Codewith exec is remote-capable like codex. OpenLoops rejects CodewithextraArgsthat try to forceexec,--ephemeral,--json, or other exec launch flags that the adapter already manages. - AI Copilot and OpenCode use
run --format json --pure. OpenCode requires an explicit provider/model id because ambient OpenCode config is machine-specific. - Cursor is CLI-first for now via the standalone
agent -pbinary. OpenLoops no longer falls back tocursor agent; install the standalone Cursor Agent CLI so preflight and scheduled runs use the same executable. - Codex uses
codex --ask-for-approval never exec --json --ephemeral --skip-git-repo-check, with--add-dirfor explicit extra writable directories where supported. - Agent prompts are sent through child stdin instead of argv where the provider supports stdin, including Codewith
exec(which reads instructions from stdin when no positional prompt is given), so the prompt never lands on argv. - When
--accountor a stepaccountis set, OpenLoops resolvesaccounts env <profile> --tool <tool>before spawning the target, strips inherited tool home/API-key variables, and applies the selected profile only to that process. Missing account profiles fail before the provider binary receives the prompt. --auth-profileand stepauthProfileare provider-native auth selectors. They currently apply to Codewith and are passed to Codewith as--auth-profile <name>on theexecinvocation; they do not call OpenAccounts.--sandboxmaps to provider-native sandbox flags. Codewith/Codex acceptread-only,workspace-write, ordanger-full-access; Cursor acceptsenabledordisabled.--permission-modemapsplan,auto, andbypasswhere the provider supports it. Claude uses native permission modes, Cursor maps bypass to--force, and OpenCode/AICopilot map bypass to--dangerously-skip-permissions.--variantis provider-specific reasoning/model effort. Claude maps it to--effort, Codewith/Codex map it tomodel_reasoning_effort, and OpenCode/AICopilot pass--variant.- Daemon and scheduled runs prepend common user executable directories such as
~/.local/binand~/.bun/binbefore resolving provider CLIs.
For production loops that can mutate repos, use disposable worktrees (--worktree-mode required / worktreeMode=required) and explicit prompts that name allowed write scope. Worktrees are executor-enforced: when a target carries worktree metadata, the executor prepares and enters the git worktree before spawning the child process, records the worktree it entered, and with mode=required fails the run closed instead of falling back to the original checkout when preparation fails. Existing managed worktrees are reused only after top-level and git-common-dir checks; a clean detached or stale-branch checkout may be reattached to the expected generated branch, while dirty or unsafe mismatches fail with cleanup evidence. Remote machine runs with a required worktree apply the same checks on the remote side before the target executes.
.github/workflows/ecr-candidate.yml is the repository workflow for building
an AWS ECR release candidate. It is manual and source-only: it does not update
latest, ECS task definitions, services, or deployments. The job checks out
an exact full commit SHA, proves that commit is reachable from origin/main,
builds the Dockerfile runner target natively for linux/arm64, blocks on
local Trivy critical/high findings, and only then assumes the ECR push role.
After the immutable push it waits for ECR scanning and blocks on critical/high
findings there as well. The retained artifact contains the local scan report,
a CycloneDX SBOM, an unsigned in-toto/SLSA provenance statement, and bounded
ECR scan counts. The statement is evidence, not a cryptographic attestation.
Before the first run, repository administrators must create a protected GitHub
environment named ecr-candidate with required reviewers and no deployment
branches other than main. Configure these repository variables (not secrets):
AWS_REGION: AWS region containing the existing candidate repository.AWS_ROLE_ARN: OIDC role dedicated to this workflow.ECR_REPOSITORY: existing ECR repository name. It must use immutable tags and scan-on-push.
The OIDC role trust must constrain token.actions.githubusercontent.com:aud
to sts.amazonaws.com and token.actions.githubusercontent.com:sub to exactly
repo:hasna/loops:environment:ecr-candidate. Its permissions should allow
ecr:GetAuthorizationToken on *, and only these actions on the configured
repository ARN: ecr:DescribeRepositories, ecr:BatchCheckLayerAvailability,
ecr:GetDownloadUrlForLayer, ecr:BatchGetImage, ecr:InitiateLayerUpload,
ecr:UploadLayerPart, ecr:CompleteLayerUpload, ecr:PutImage,
ecr:DescribeImages, and ecr:DescribeImageScanFindings. No static AWS access
key is used.
Dispatch only after the source commit is merged to main:
sha="$(git rev-parse origin/main)"
gh workflow run ecr-candidate.yml --repo hasna/loops --ref main \
-f source_sha="${sha}" -f confirmation="push ${sha}"Dispatch is an AWS mutation and requires the applicable operator approval and fresh release preflight. Do not run it merely because the source workflow has merged. Review the workflow summary and retained evidence; promote only by the reported immutable digest through the separate deployment process.