Skip to content

Repository files navigation

Uriel

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.

Architecture

  • apps/worker: local HTTP worker daemon for jobs, state, artifacts, and QA.
  • apps/cli: urielctl for 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.

Status

Implemented v1 surfaces:

  • GET /health
  • GET /ready (authenticated when URIEL_WORKER_TOKEN is set)
  • GET /jobs
  • POST /jobs
  • GET /jobs/:id
  • GET /jobs/:id/events
  • GET /jobs/:id/artifacts/:name
  • POST /jobs/:id/approve/:step-id
  • POST /jobs/:id/cancel
  • urielctl submit/status/approve/cancel, including submit --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

Quick Start

nix develop --command pnpm install
nix develop --command pnpm check

Run a worker locally:

export URIEL_WORKER_TOKEN=dev-token
nix run .#uriel-worker -- serve --host 127.0.0.1 --port 8788

Submit 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 both

The --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/uriel

Verify 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.

Completion Webhooks

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/json
  • x-uriel-event: job.completed or job.failed
  • x-uriel-job-id: <job-id>
  • x-uriel-signature: sha256=<hex HMAC> when URIEL_CALLBACK_SECRET is 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.

NixOS Worker

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" ];
          };
        }
      ];
    };
  };
}

Secrets

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_TOKEN
  • URIEL_CALLBACK_SECRET
  • URIEL_CALLBACK_TIMEOUT_SECONDS
  • URIEL_ALLOWED_REPOS
  • GH_TOKEN
  • OPENCODE_MODEL
  • URIEL_ADAPTER_HARNESS
  • URIEL_CLAUDE_MODEL
  • URIEL_CODEX_MODEL
  • URIEL_CODEX_EFFORT
  • URIEL_MAX_CONCURRENT_JOBS
  • URIEL_MAX_HEAVY_JOBS (defaults to 1; clamped to total jobs and dedicated Android slots)
  • URIEL_CAPACITY_ENFORCE_SWAP (defaults to false on macOS and true elsewhere)
  • URIEL_CAPACITY_MIN_FREE_MEMORY_MB (defaults to 4096)
  • URIEL_CAPACITY_MAX_SWAP_USED_MB (defaults to 32768)
  • URIEL_CAPACITY_MIN_FREE_DISK_MB (defaults to 20480)
  • URIEL_CAPACITY_RETRY_SECONDS (defaults to 15)
  • URIEL_CLEANUP_GRACE_SECONDS (defaults to 10)
  • URIEL_DEVICE_IDLE_TTL_SECONDS (defaults to 300)
  • URIEL_ARTIFACT_RETENTION_DAYS (defaults to 7)
  • URIEL_LEDGER_RETENTION_DAYS (defaults to 30)
  • URIEL_MAX_JOB_EVENTS (defaults to 500)
  • URIEL_WATCHDOG_INTERVAL_SECONDS (defaults to 30)
  • URIEL_WATCHDOG_FAILURE_THRESHOLD (defaults to 3)
  • URIEL_WATCHDOG_COOLDOWN_SECONDS (defaults to 300)
  • URIEL_READINESS_HISTORY_RETENTION_DAYS (defaults to 7)
  • URIEL_READINESS_HISTORY_MAX_SAMPLES (defaults to 25000)
  • URIEL_READINESS_HISTORY_MAX_GAP_SECONDS (defaults to three watchdog intervals)
  • URIEL_SMOKE_HISTORY_LIMIT (defaults to 50)
  • URIEL_ENABLE_BROWSER_QA
  • URIEL_ENABLE_ANDROID_QA
  • URIEL_ENABLE_IOS_QA
  • URIEL_ADAPTER_REPO_BOOTSTRAP
  • URIEL_BROWSER_URL
  • URIEL_ANDROID_AVD
  • URIEL_ANDROID_AVDS
  • URIEL_ANDROID_AVD_PREFIX (defaults to uriel_)
  • URIEL_ANDROID_ADB_PATH
  • URIEL_ANDROID_EMULATOR_PATH
  • URIEL_ANDROID_BOOT_TIMEOUT_SECONDS
  • URIEL_ANDROID_APK_URL
  • URIEL_ANDROID_APK_SHA256
  • URIEL_ANDROID_APK_FINGERPRINT
  • URIEL_ANDROID_COMPAT_FILE
  • URIEL_ANDROID_APK_MANIFEST_FILE
  • URIEL_ANDROID_APK_REFRESH_CMD
  • URIEL_ANDROID_APK_REFRESH_TIMEOUT_SECONDS (defaults to 300)
  • URIEL_ANDROID_APP_PACKAGE
  • URIEL_IOS_SIMULATOR_UDID
  • URIEL_IOS_SIMULATOR_UDIDS
  • URIEL_IOS_SIMULATOR_NAME
  • URIEL_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.

Migrating a macOS worker to dedicated AVDs

  1. Leave the launchd-managed worker running so /health remains available.
  2. In Android Studio Device Manager (or avdmanager), clone/create separate devices named uriel_dungeon_1 and uriel_dungeon_2. Do not rename or delete the interactive dungeonqa_pool_* devices.
  3. Set URIEL_ANDROID_AVD_PREFIX=uriel_ and URIEL_ANDROID_AVDS=uriel_dungeon_1,uriel_dungeon_2 in the worker's environment file. Keep the pinned APK URL, SHA-256, and package together.
  4. Restart only the worker service. Confirm /health remains 200, then call authenticated /ready; it must name both dedicated AVDs and report android.avd.ownership as pass before submitting Android work.
  5. Submit one Android smoke job. Confirm its events show the worker-owned AVD, package SHA-256, provisioning reason, and pm-clear isolation 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.

Profiles And Adapters

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.

License

Apache-2.0.

About

Open-source, local-first NixOS remote coding and QA worker for GitHub-hosted Nix projects

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages