Skip to content

Viewer Plug-ins

A Hawk deployment can offer viewer UIs besides the built-in Inspect viewer. Users pick one from the Open in menu next to eval sets, logs and samples, and can make one their default (see Web Viewer).

There are two kinds:

Kind Where it runs Auth Hawk creates
same_origin A container image served on the Hawk viewer host under /<id>/ The user's Hawk session cookies, for free An ECS service, target group and ALB path rule
external Another web host, or a native app registered for a URL scheme The viewer's own Nothing; Hawk only builds the link

Configuration

Viewers are configured per stack in the hawk:viewers Pulumi config list. The API serves the public fields (id, name, kind, url_templates) from the unauthenticated GET {api}/auth/config.

hawk:viewers:
  - id: template
    name: Template viewer
    kind: same_origin
    image: ghcr.io/example/hawk-viewer-template@sha256:<64 hex chars>
    cpu: 256        # optional, Fargate CPU units
    memory: 512     # optional, MiB; must be a Fargate size for the cpu
    url_templates:
      eval_set: /template/?eval_set_id={eval_set_id}
      log: /template/?eval_set_id={eval_set_id}&log_file={log_file}
      sample: /template/?eval_set_id={eval_set_id}&log_file={log_file}&sample_id={sample_id}&epoch={epoch}
  - id: eagle
    name: Eagle (desktop)
    kind: external
    url_templates:
      log: eagle://open/{eval_set_id}/{log_path}
      sample: eagle://open/{eval_set_id}/{log_path}?sample={sample_id}_epoch_{epoch}
Field Required Notes
id yes ^[a-z][a-z0-9-]{0,30}$, unique, not reserved (below)
name yes Menu label, 1–64 characters
kind yes same_origin or external
url_templates yes At least one of eval_set, log, sample; a viewer is offered only at levels it has a template for
image same_origin: this or github_repository Must be pinned by digest: <repository>@sha256:<digest>
github_repository same_origin: this or image <owner>/<repo> whose CI pushes the image to a Hawk-owned ECR repository; see Private images via ECR
github_ids no, with github_repository {owner: <id>, repository: <id>}; required for repositories on GitHub's immutable OIDC subject format
github_refs no, with github_repository Exact git refs allowed to push (no wildcards); default ["refs/heads/main"]
image_digest no, with github_repository sha256:<digest> of the pushed image; the plug-in is deployed only once this is set
cpu, memory no, same_origin only Defaults 256 and 512

URL templates

Templates use {placeholder} syntax ({{ and }} for literal braces). Available placeholders grow with the level:

Level Placeholders
eval_set {eval_set_id}
log the above, plus {log_file} (file name within the eval set) and {log_path} (<eval_set_id>/<log_file>)
sample the above, plus {sample_id}, {epoch} and {sample_uuid}

Where a sample's {sample_uuid} isn't known, a viewer whose sample template uses it is left out of the menu.

Encoding. Each value is percent-encoded per /-separated segment, so {log_path} keeps its slash while ?, &, #, =, + and spaces are encoded. URLSearchParams returns the original value. Values containing a . or .. segment are refused.

Validation. pulumi up rejects reserved ids (hawk and the Hawk viewer's own routes, listed in infra/lib/config.py), unpinned images, invalid cpu/memory, and ids whose AWS resource names are too long or collide on target group name or ALB rule priority. At startup the API drops, and logs at ERROR (Ignoring viewer <id>: …), any entry whose template uses a placeholder not available at its level (or a format spec/conversion), whose same_origin template doesn't start with /<id>/, or whose external template isn't an absolute URL or uses the javascript:, data:, vbscript:, blob: or file: scheme; duplicate ids keep the first entry. The API still starts, and the dropped viewer just isn't offered. The front end re-checks resolved URLs before linking to them.

The same-origin plug-in contract

A same-origin plug-in is a static web app in a container. The ALB forwards https://<viewer host>/<id>/* to it with the full path (the prefix is not stripped), so the app must be built with /<id>/ as its base path.

Port 8080, HTTP
Health check GET /health → 200
Base path Everything under /<id>/; client-side routes fall back to /<id>/index.html
Environment HAWK_VIEWER_PLUGIN_ID, HAWK_API_URL (e.g. https://api.hawk.example.org), HAWK_VIEWER_ORIGIN (e.g. https://viewer.hawk.example.org)
Runtime config /<id>/hawk-config.js sets window.__HAWK_CONFIG__
Headers Strict-Transport-Security, X-Frame-Options: DENY, Referrer-Policy: strict-origin-when-cross-origin, X-Content-Type-Options: nosniff
Filesystem Runs as a non-root user with a read-only root filesystem; only /tmp is writable
Service workers Not available: once any plug-in is deployed, the ALB answers every service-worker script request on the viewer host with 403

The base image implements all of this. Load the config script before the app:

<script src="/<id>/hawk-config.js"></script>
window.__HAWK_CONFIG__ = {
  pluginId: "template",
  basePath: "/template/",
  apiUrl: "https://api.hawk.example.org",
  viewerOrigin: "https://viewer.hawk.example.org",
};

Authentication

The session lives in HttpOnly cookies on the API host. A same-origin plug-in shares the viewer origin, which is on the API's CORS and CSRF allow-list, so it authenticates like the Hawk viewer:

  1. Call the API with credentials: 'include'. No Authorization header and no token handling in JavaScript.
  2. On a 401, POST {api}/auth/refresh (no body, credentials: 'include'). A 200 means the access cookie was rotated: retry the request once.
  3. If the refresh also fails, send the user to the Hawk viewer (viewerOrigin), which signs them in. It has no return URL, so they reopen the plug-in from Hawk afterwards.

hawkFetch in the template's src/hawk-client.ts implements this flow.

Data APIs

All paths are relative to apiUrl. Log paths are relative to the evals prefix, i.e. <eval_set_id>/<file>, which is what {log_path} expands to. Access control is the same as in the Hawk viewer: the user must hold the model groups of the eval set.

Endpoint Returns
GET /view/logs/logs?log_dir=<eval_set_id> The eval set's logs: {log_dir, files: [{name, mtime, task, task_id}]}, name being a log path (<eval_set_id>/<file>, whereas {log_file} is the bare file)
GET /view/logs/log-headers?file=<log_path>[&file=…] Log headers (Inspect EvalLog without samples)
GET /view/logs/logs/<log_path> A whole log as JSON (can be large)
GET /view/logs/log-info/<log_path> {size, …}: the log's size in bytes
GET /view/logs/log-bytes/<log_path>?start=<n>&end=<n> Raw bytes start through end inclusive (end is clamped to the file; 416 if start is past it)
GET /view/logs/eval-set?log_dir=<eval_set_id> Eval-set info
GET /view/logs/log-download-url/<log_path> {url, filename}: a short-lived presigned S3 URL for the raw .eval
GET /meta/eval-sets, /meta/evals?eval_set_id=, /meta/samples?eval_set_id= Warehouse listings (paginated)
GET /meta/samples/<sample_uuid>, /scores, /events One sample's metadata, scores and events from the warehouse

The /view/logs endpoints are Inspect's view-server protocol (inspect_ai._view.fastapi_server), so an Inspect-based viewer can point its API client at {apiUrl}/view/logs. Path segments in log paths must be percent-encoded per segment, like the URL templates.

Reading .eval logs incrementally

An .eval log is a zip, so a plug-in can read single samples without downloading the whole file: fetch the size from log-info, read the central directory from the end with log-bytes, then fetch only the entries it needs.

Entry Contents
header.json The log header
summaries.json Sample summaries; while an eval is still running, _journal/summaries/<n>.json instead
samples/<sample_id>_epoch_<epoch>.json One sample

Entries are zstd-compressed (method 93, the default in recent Inspect) or deflated (method 8). Browsers' DecompressionStream only handles deflate, so bundle a zstd decoder. METR/spyglass's zip reader is a worked example; the template's getLogInfo and getLogBytes wrap the two endpoints.

Building a plug-in

Base image. ghcr.io/metr/hawk-viewer-plugin-base (source: hawk/services/modules/viewer-plugin-base/) is published for amd64 and arm64 on each Hawk release (<version>, <major>, latest) and on each main change (main-<sha>). It is the Hawk viewer's hardened nginx image plus an entrypoint that validates the environment, writes hawk-config.js and the nginx config to /tmp, and starts nginx. Copy your built app over its placeholder:

FROM ghcr.io/metr/hawk-viewer-plugin-base:<version>@sha256:<digest>
COPY dist/ /usr/share/nginx/html/

The release workflow's job summary shows each published digest. Plug-ins with their own backend can use any image that honours the contract above.

Template. hawk/viewer-plugins/template/ is a dependency-free Vite + TypeScript app that lists an eval set's logs and shows a log header. Copy it to start a plug-in; src/hawk-client.ts (config, hawkFetch, parseTarget, log API calls) can be copied into other plug-ins as-is. The build-time id must equal the viewer's id, and BASE_IMAGE is required:

HAWK_VIEWER_PLUGIN_ID=my-viewer pnpm build   # served under /my-viewer/
docker build -t my-viewer:dev \
  --build-arg HAWK_VIEWER_PLUGIN_ID=my-viewer \
  --build-arg BASE_IMAGE=ghcr.io/metr/hawk-viewer-plugin-base:<version>@sha256:<digest> .

pnpm dev serves the app at http://localhost:5173/<id>/. Without hawk-config.js the client reads VITE_HAWK_API_URL and VITE_HAWK_VIEWER_ORIGIN (see .env.example), defaulting to a local Hawk API on :8080 and viewer on :3000. The session cookie is SameSite=Strict, so sign in through a locally running Hawk viewer first; a deployed stack's cookies don't reach the dev server.

To check an image locally the way Hawk runs it:

hawk/services/modules/viewer-plugin-base/smoke-test.sh my-viewer:dev my-viewer

Deploying

  1. Build the image and push it to a registry the stack's ECS tasks can pull from. The ECS task execution role pulls without registry credentials, so use a public image or an ECR repository in the stack's account. For a private image, use github_repository instead.
  2. Add the entry to hawk:viewers with the image digest, then pulumi up.
  3. Open an eval set in Hawk; the viewer appears in Open in.

Private images via ECR

A github_repository entry has the plug-in repository's CI push straight into an ECR repository the stack owns, authenticating with GitHub Actions OIDC:

hawk:viewers:
  - id: spyglass
    name: Spyglass
    kind: same_origin
    github_repository: example-org/spyglass
    github_ids: {owner: 123456, repository: 456789}  # see below
    github_refs: ["refs/heads/main"]   # optional, this is the default
    image_digest: sha256:<64 hex chars> # set in step 5
    url_templates:
      log: /spyglass/?eval_set_id={eval_set_id}&log_file={log_file}

For each such entry the stack creates an ECR repository <env>/hawk/viewer-plugin-<id> (immutable tags, scan on push, no expiry, so a pinned digest is never deleted) and an IAM role <env>-hawk-vp-<id>-push that can push to that repository only. The role trusts the account's token.actions.githubusercontent.com OIDC provider with aud = sts.amazonaws.com and sub exactly matching each entry in github_refs. A job that declares environment: gets a different sub and can't assume the role.

Repositories created, renamed or transferred after 2026-07-15 (or opted in) use GitHub's immutable subject format, repo:<owner>@<owner-id>/<repo>@<repo-id>:ref:<ref>. Set github_ids for those: gh api repos/<owner>/<repo> --jq '{owner: .owner.id, repository: .id}'. Without github_ids the role trusts the older repo:<owner>/<repo>:ref:<ref>.

A dev stack that inherits hawk:viewers from stg runs stg's deployed image for each github_repository viewer (stg's viewer_plugin_images output) and creates no repository or role of its own. Set hawk:viewers on the dev stack to give it its own.

  1. Add the entry without image_digest and pulumi up. This creates the repository and role only; the viewer has no service and isn't offered in Open in yet.
  2. Read the viewer_plugin_ecr stack output: <id> → repository_url, push_role_arn.
  3. Configure the plug-in repository's CI with both values (the job below reads them from repository variables).
  4. CI pushes an immutable tag and prints the image digest.
  5. Set image_digest to that digest and pulumi up. This is the admin's approval of the image: CI can push, but only a digest in stack config runs.
jobs:
  push-image:
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4
        with:
          role-to-assume: ${{ vars.HAWK_VIEWER_PUSH_ROLE_ARN }}
          aws-region: us-west-2
      - uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7
      - env:
          IMAGE: ${{ vars.HAWK_VIEWER_REPOSITORY_URL }}:main-${{ github.sha }}
        run: |
          docker build --platform linux/arm64 -t "$IMAGE" .
          docker push "$IMAGE"
          docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE" | tee -a "$GITHUB_STEP_SUMMARY"

Build for the stack's hawk:cpuArchitecture.

Each same-origin viewer becomes an ECS service hawk-vp-<id> logging to <env>/hawk/viewer-plugin-<id>, behind an ALB rule matching host = <viewer host> and path = /<id>/*. Rule priorities are 30000 + crc32(id) % 9999; once any plug-in exists, the Hawk viewer's host-only rule moves to 40000 so the plug-in rules are evaluated first.

Symptom Likely cause
/<id>/ shows the Hawk viewer's "not found" page The plug-in rule isn't ahead of the main viewer rule: check the listener's rule priorities
503 from the ALB No healthy targets: check /health on port 8080 and the task logs
Page loads, assets 404 Built with the wrong base path; rebuild with the viewer's id
Task exits at start with hawk-viewer-plugin: … A missing or invalid HAWK_* variable, or /tmp not writable
API calls fail with CORS errors The API's allow-list (HAWK_API_VIEWER_URL / HAWK_API_CORS_ALLOWED_ORIGIN_REGEX) doesn't match the viewer origin
401 persists after signing in to Hawk The session cookies aren't sent: the API and viewer hosts must share a registrable domain (the access cookie is SameSite=Strict)
403 on log endpoints The user lacks the eval set's model groups, as in the Hawk viewer
pulumi up rejects the config See the validation rules above; the error names the offending entry
A configured viewer never appears in the Open-in menu The API dropped it: search the API logs for Ignoring viewer

External viewers

An external viewer is just a link. Hawk opens http(s) URLs on another host in a new tab, and custom schemes in the same window so the OS can hand them to the registered app. External viewers don't get the Hawk session: they authenticate on their own and call the same data APIs with a bearer token (and need to be on the API's CORS allow-list if they run in a browser).

In the configuration example above, Eagle is a desktop log viewer registered for eagle://. It signs in with its own OIDC PKCE login and downloads logs through /view/logs/log-download-url/<log_path>.

Security

A same-origin plug-in runs on the Hawk viewer's origin with the user's session: it can call any API the user can, read the same data, and act as the user. Treat it as first-party code.

  • Only deployment admins add viewers, through stack config.
  • Pin images by digest (enforced), and update the digest deliberately.
  • Review plug-in source and dependencies as you would a change to Hawk itself, and rebuild on the current base image to pick up nginx fixes.
  • Don't render API data as HTML; set text content.
  • Service-worker registration is blocked on the viewer host, because a worker scoped to / would control every Hawk page and outlive the plug-in.
  • Reject . and .. segments in values read from the page URL before building API paths from them (the template's parseTarget does).
  • External viewers get no session, but their links still run on user machines: only list apps and hosts you trust.