Uriel is an open-source, organization-agnostic remote coding and QA framework
for GitHub-hosted Nix projects. It runs as a local-first NixOS worker and
treats flake.nix, direnv, and repo-local justfiles as the source of truth
for how a project builds, tests, and runs.
The point is simple: remote coding agents should understand Nix. Uriel supplies
a reproducible NixOS host, fresh Git worktrees, Nix/direnv-aware command
execution, browser/Android/iOS QA capture, local artifacts, and PR creation.
Each job also emits an evidence.json manifest for command results, discovered
repo contract data, QA summaries, artifacts, and PR links.
Uriel core does not assume any company, issue tracker, secret manager, hosted coordination service, or chat platform. Those concerns live behind optional adapters or outside the worker API.
apps/worker: local HTTP worker daemon for jobs, state, artifacts, and QA.apps/cli:urielctlfor submitting and inspecting jobs.packages/core: shared job schemas, branch naming, profile rule matching, adapter contracts, and webhook signature helpers for future integrations.nix/modules: NixOS and Home Manager modules.
No hosted coordination service is required. The worker stores JSON job state under
/var/lib/uriel/jobs, artifacts under /var/lib/uriel/artifacts, bare repo
caches under /var/lib/uriel/repos, and job worktrees under
/var/lib/uriel/worktrees.
Implemented v1 surfaces:
GET /healthGET /ready(authenticated whenURIEL_WORKER_TOKENis set)GET /jobsPOST /jobsGET /jobs/:idGET /jobs/:id/eventsGET /jobs/:id/artifacts/:namePOST /jobs/:id/approve/:step-idPOST /jobs/:id/cancelurielctl submit/status/approve/cancel, includingsubmit --kind,--ref,--checks-file, and--callback-url- checklist verification jobs and signed job-completion webhooks
uriel-worker serve/run- NixOS module
services.uriel-worker - Home Manager module
programs.uriel
nix develop --command pnpm install
nix develop --command pnpm checkRun a worker locally:
export URIEL_WORKER_TOKEN=dev-token
nix run .#uriel-worker -- serve --host 127.0.0.1 --port 8788Submit a job:
export URIEL_WORKER_URL=http://127.0.0.1:8788
export URIEL_WORKER_TOKEN=dev-token
nix run .#urielctl -- submit \
--repo https://github.com/acme/mobile-app.git \
--prompt "Fix the failing mobile registration test" \
--issue APP-1234 \
--repo-bootstrap direnv \
--qa bothThe --qa flag accepts none, browser, android, ios, both, or all.
both retains its browser-and-Android meaning; use all to run browser,
Android, and iOS QA.
Submit a checklist verification job against an exact ref:
[
{
"id": "home.visible",
"text": "The home page renders without an error.",
"context": "Capture a screenshot after the page finishes loading."
}
]nix run .#urielctl -- submit \
--repo https://github.com/example/application.git \
--prompt "Verify the release candidate" \
--kind verify \
--ref 0123456789abcdef0123456789abcdef01234567 \
--checks-file checks.json \
--callback-url https://consumer.example/hooks/urielVerify jobs create a worktree at the requested ref, run the harness and QA,
collect per-check verdicts and evidence, and do not push a branch or open a
pull request. urielctl status <job-id> prints stored check results after the
job JSON. For each check, the harness captures a before screenshot, a short
during recording, and an after screenshot; static-state checks need only a
screenshot.
When a job has callbackUrl, the worker sends the callback after writing the
final evidence manifest. Completed jobs use job.completed; failed jobs use
job.failed. Requests include:
content-type: application/jsonx-uriel-event: job.completedorjob.failedx-uriel-job-id: <job-id>x-uriel-signature: sha256=<hex HMAC>whenURIEL_CALLBACK_SECRETis configured
The signature is an HMAC-SHA256 over the exact JSON request body. Consumers should compute the HMAC with the shared secret and compare it in constant time. The payload has this shape:
{
"event": "job.completed",
"job": {
"id": "job_123",
"kind": "verify",
"repo": "https://github.com/example/application.git",
"branch": "codex/verify-release",
"source": "api",
"status": "completed",
"ref": "0123456789abcdef0123456789abcdef01234567",
"metadata": {}
},
"summary": "Job completed.",
"checks": {
"requested": [{ "id": "home.visible", "text": "The home page renders without an error." }],
"results": [{ "id": "home.visible", "verdict": "pass", "artifacts": ["home.png"] }]
},
"artifacts": [{ "name": "home.png", "kind": "screenshot", "contentType": "image/png", "size": 12345 }],
"pullRequest": null
}The worker makes three attempts. Each attempt uses
URIEL_CALLBACK_TIMEOUT_SECONDS (60 seconds by default); retries wait 5 seconds
and then 30 seconds. Callback failures are recorded as worker events and never
change the job outcome. Without URIEL_CALLBACK_SECRET, the worker emits one
warning and sends the callback without a signature.
The worker is designed for a generic x86_64 NixOS host with KVM enabled.
The worker boots its configured AVD only where hardware virtualization is
available (/dev/kvm on Linux, or macOS); otherwise Android QA runs against
an already-attached device and is skipped with a diagnostic when none is
attached.
iOS QA is available on macOS hosts with Xcode command-line tools and an
installed Simulator runtime. The worker can bind to a configured simulator
UDID, boot a simulator by name, or use a single already-booted simulator. It
skips iOS QA with a diagnostic when xcrun or a usable simulator is
unavailable.
{
inputs.uriel.url = "github:uriel-agent/uriel";
outputs = { self, nixpkgs, uriel, ... }: {
nixosConfigurations.worker = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
uriel.nixosModules.uriel-worker
./hardware-configuration.nix
{
services.uriel-worker = {
enable = true;
environmentFiles = [ "/run/secrets/uriel-worker.env" ];
};
}
];
};
};
}Uriel does not manage secrets. The NixOS host supplies environment variables to
the worker through systemd environment files, typically generated by
sops-nix, agenix, or another host-managed secret mechanism.
Common variables:
URIEL_WORKER_TOKENURIEL_CALLBACK_SECRETURIEL_CALLBACK_TIMEOUT_SECONDSURIEL_ALLOWED_REPOSGH_TOKENOPENCODE_MODELURIEL_ADAPTER_HARNESSURIEL_CLAUDE_MODELURIEL_CODEX_MODELURIEL_CODEX_EFFORTURIEL_MAX_CONCURRENT_JOBSURIEL_MAX_HEAVY_JOBS(defaults to1; clamped to total jobs and dedicated Android slots)URIEL_CAPACITY_ENFORCE_SWAP(defaults tofalseon macOS andtrueelsewhere)URIEL_CAPACITY_MIN_FREE_MEMORY_MB(defaults to4096)URIEL_CAPACITY_MAX_SWAP_USED_MB(defaults to32768)URIEL_CAPACITY_MIN_FREE_DISK_MB(defaults to20480)URIEL_CAPACITY_RETRY_SECONDS(defaults to15)URIEL_CLEANUP_GRACE_SECONDS(defaults to10)URIEL_DEVICE_IDLE_TTL_SECONDS(defaults to300)URIEL_ARTIFACT_RETENTION_DAYS(defaults to7)URIEL_LEDGER_RETENTION_DAYS(defaults to30)URIEL_MAX_JOB_EVENTS(defaults to500)URIEL_WATCHDOG_INTERVAL_SECONDS(defaults to30)URIEL_WATCHDOG_FAILURE_THRESHOLD(defaults to3)URIEL_WATCHDOG_COOLDOWN_SECONDS(defaults to300)URIEL_READINESS_HISTORY_RETENTION_DAYS(defaults to7)URIEL_READINESS_HISTORY_MAX_SAMPLES(defaults to25000)URIEL_READINESS_HISTORY_MAX_GAP_SECONDS(defaults to three watchdog intervals)URIEL_SMOKE_HISTORY_LIMIT(defaults to50)URIEL_ENABLE_BROWSER_QAURIEL_ENABLE_ANDROID_QAURIEL_ENABLE_IOS_QAURIEL_ADAPTER_REPO_BOOTSTRAPURIEL_BROWSER_URLURIEL_ANDROID_AVDURIEL_ANDROID_AVDSURIEL_ANDROID_AVD_PREFIX(defaults touriel_)URIEL_ANDROID_ADB_PATHURIEL_ANDROID_EMULATOR_PATHURIEL_ANDROID_BOOT_TIMEOUT_SECONDSURIEL_ANDROID_APK_URLURIEL_ANDROID_APK_SHA256URIEL_ANDROID_APK_FINGERPRINTURIEL_ANDROID_COMPAT_FILEURIEL_ANDROID_APK_MANIFEST_FILEURIEL_ANDROID_APK_REFRESH_CMDURIEL_ANDROID_APK_REFRESH_TIMEOUT_SECONDS(defaults to300)URIEL_ANDROID_APP_PACKAGEURIEL_IOS_SIMULATOR_UDIDURIEL_IOS_SIMULATOR_UDIDSURIEL_IOS_SIMULATOR_NAMEURIEL_IOS_BOOT_TIMEOUT_SECONDS
For safe concurrent Android QA, configure URIEL_ANDROID_AVDS with one
dedicated worker AVD per slot. Every name must start with
URIEL_ANDROID_AVD_PREFIX; Uriel rejects dungeonqa_pool_*, physical devices,
unconfigured emulators, and implicit attached-device selection. Uriel leases
each AVD exclusively for a whole job and exports its resolved serial as
ANDROID_SERIAL to the harness. The three APK variables
form an optional all-or-nothing provisioner: Uriel downloads the pinned APK
once, verifies its SHA-256, and ensures the configured package is installed on
the leased device before the harness starts. URIEL_ANDROID_APK_URL may also
be a file:// URL when an operator maintains the signed APK on the worker
host; the checksum is still verified before every install. Provisioning proves
the serial-to-AVD ownership binding before package changes, cleanly reinstalls
on version/signature conflicts, and clears application data before each job.
When URIEL_ANDROID_APK_FINGERPRINT and URIEL_ANDROID_COMPAT_FILE are both
set, Android jobs also require the provisioned APK fingerprint to appear as a
40-character hexadecimal string anywhere in the JSON compatibility file at
that repository-relative path. A mismatch fails the job before device setup so
the worker cannot silently test a revision with a stale app binary.
Configure URIEL_ANDROID_APK_REFRESH_CMD (alongside
URIEL_ANDROID_APK_MANIFEST_FILE) so the worker fixes a stale APK instead of
just failing on it. On a compatibility mismatch the worker runs the refresh
command once, re-reads the manifest, and proceeds with the refreshed APK if
it's now compatible; only a build that's still incompatible after refreshing
(or a refresh command that fails, or no refresh command configured) reaches
the terminal failure. See docs/deployment.md for the
manifest contract and an example refresh command.
- Leave the launchd-managed worker running so
/healthremains available. - In Android Studio Device Manager (or
avdmanager), clone/create separate devices nameduriel_dungeon_1anduriel_dungeon_2. Do not rename or delete the interactivedungeonqa_pool_*devices. - Set
URIEL_ANDROID_AVD_PREFIX=uriel_andURIEL_ANDROID_AVDS=uriel_dungeon_1,uriel_dungeon_2in the worker's environment file. Keep the pinned APK URL, SHA-256, and package together. - Restart only the worker service. Confirm
/healthremains200, then call authenticated/ready; it must name both dedicated AVDs and reportandroid.avd.ownershipaspassbefore submitting Android work. - Submit one Android smoke job. Confirm its events show the worker-owned AVD,
package SHA-256, provisioning reason, and
pm-clearisolation action.
Rollback is configuration-only: stop new Android submissions, restore the previous environment file, and restart the worker. Uriel will refuse the old interactive AVD names, but the control plane and non-Android jobs remain online.
GET /health is a cheap liveness check and remains available without worker
authentication. GET /ready verifies Android tool executability, adb
responsiveness, configured AVD availability, cold-boot capability, and APK
provisioning configuration without booting an emulator. It returns 503 with
per-check remediation when the worker cannot safely accept Android QA. Tool
resolution prefers explicit URIEL_ANDROID_ADB_PATH and
URIEL_ANDROID_EMULATOR_PATH values, then stable SDK roots, then PATH.
The worker also admits heavy jobs only while configured RAM, disk, worker-slot,
and (when enabled) swap reserves are available. Admission happens before a job receives an
emulator lease, and the worker rechecks immediately before emulator, coding
harness, and QA process launches. Temporary pressure leaves jobs queued in FIFO
order and retries automatically while /health stays online. /ready exposes
the current readings, configured limits, active/queued ownership, missing
readings, and the blocking reason. When a reading is unavailable, Uriel uses a
conservative single-heavy-job policy instead of assuming unlimited capacity.
On macOS, cumulative swap is retained in telemetry but is diagnostic-only by
default because it commonly remains high after live memory pressure recovers;
the existing memory_pressure reading remains enforced. Set
URIEL_CAPACITY_ENFORCE_SWAP=true to opt back into the absolute swap cap. Linux
continues enforcing the cap by default.
Every job durably journals its exact worktree, artifact directory, harness PID
and start identity, device lease, AVD/serial binding, and package marker under
the worker state directory. Terminal cleanup is idempotent: it rechecks the
journaled owner and live process/device identity before terminating anything,
removes the job worktree, releases markers and leases, and retains artifacts
only for the configured window. Completed jobs leave worker AVDs warm for the
idle TTL; failures and cancellations reap them immediately. Startup
reconciliation cleans resources left by a crash before accepting queued work.
Interactive dungeonqa_pool_* AVDs, physical devices, unjournaled processes,
and paths outside worker roots are never terminated or deleted.
GET /status is an authenticated, secret-free operational snapshot covering
readiness causes, queue depth, active jobs and Android slots, capacity readings,
active cleanup resources, provisioning configuration, watchdog state, and the
latest readiness smoke. It also reports a time-weighted availability window
(24 hours by default, or bounded by ?windowSeconds=), including measured and
excluded time, coverage, longest probe gap, and ready percentage. Watchdog
probes append to a crash-tolerant bounded history; time spent on queued/active
jobs or exclusive smoke maintenance is explicitly excluded rather than counted
as downtime, while self-recovery time remains measured. History I/O failures do
not take the HTTP service offline. The watchdog requires consecutive degraded probes,
never recovers across active/queued real work, rate-limits recovery by cooldown,
reconciles owned resources, restarts adb, and writes a structured actionable
alert to readiness-alerts.jsonl only if recovery does not restore readiness.
POST /smoke starts an asynchronous exclusive smoke. It refuses to start while
a real job is active or queued, passes through the same capacity governor,
cold-stops and boots a dedicated AVD, converges the pinned APK, runs
adb shell echo uriel-smoke, and tears the device down. Import and enable the
uriel-smoke NixOS module for a persistent randomized timer:
imports = [ inputs.uriel.nixosModules.uriel-smoke ];
services.uriel-smoke = {
enable = true;
environmentFile = /run/secrets/uriel-worker.env;
interval = "6h";
};On the macOS worker, keep the existing launchd service KeepAlive = true, set
ThrottleInterval to at least 10 seconds, and schedule the same authenticated
POST /smoke with a separate launchd calendar/interval job. Do not put the
worker token in program arguments; load it from the worker environment file and
pass the curl header through stdin/config. Roll out by restarting only the
worker, checking public /health, authenticated /ready and /status, then
triggering one manual smoke before enabling the schedule.
On macOS, configure URIEL_IOS_SIMULATOR_UDID to select one simulator, or
URIEL_IOS_SIMULATOR_UDIDS as a comma-separated pool for concurrent jobs.
Uriel leases each configured UDID exclusively for a whole job. When no UDID is
configured, URIEL_IOS_SIMULATOR_NAME selects a simulator to boot by name.
URIEL_IOS_BOOT_TIMEOUT_SECONDS defaults to 300.
The claude-code harness authenticates through the environment file: set
ANTHROPIC_API_KEY, or a long-lived subscription token minted with
claude setup-token (CLAUDE_CODE_OAUTH_TOKEN).
URIEL_CODEX_MODEL and URIEL_CODEX_EFFORT optionally select the model and
reasoning effort used by the codex harness.
Optional issue tracker adapters can use generic adapter variables such as
URIEL_ADAPTER_ISSUE_TRACKER, URIEL_ADAPTER_ISSUE_TRACKER_API_KEY,
URIEL_ADAPTER_ISSUE_TRACKER_TEAM_KEY, and
URIEL_ADAPTER_ISSUE_TRACKER_IN_PROGRESS_STATE.
The NixOS module exposes matching framework knobs for repo allowlists, concurrency, artifact retention, QA enablement, extra packages, and environment files.
Uriel core treats profile as an opaque string. A profile is an adopter-owned
bundle of adapter choices, not a hardcoded repository identity.
Adapter dimensions:
- Harness: OpenCode (default), Claude Code, or Codex; Hermes can be added as an optional harness
- Issue tracker: optional adapter selected by name; no default
- Repo bootstrap: optional, currently
direnv - QA capability:
browser,android, or both - Artifact storage: local worker filesystem by default
- Ingress/orchestration: local HTTP by default; Hermes or bots can sit outside Uriel core
See Adapter Contracts for the provider-neutral interfaces. See Repo Contract And Evidence for the Nix-first discovery and evidence manifest format.
Apache-2.0.