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:
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:
- Call the API with
credentials: 'include'. NoAuthorizationheader and no token handling in JavaScript. - On a 401,
POST {api}/auth/refresh(no body,credentials: 'include'). A 200 means the access cookie was rotated: retry the request once. - 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:
Deploying¶
- 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_repositoryinstead. - Add the entry to
hawk:viewerswith the image digest, thenpulumi up. - 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.
- Add the entry without
image_digestandpulumi up. This creates the repository and role only; the viewer has no service and isn't offered in Open in yet. - Read the
viewer_plugin_ecrstack output:<id>→repository_url,push_role_arn. - Configure the plug-in repository's CI with both values (the job below reads them from repository variables).
- CI pushes an immutable tag and prints the image digest.
- Set
image_digestto that digest andpulumi 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'sparseTargetdoes). - External viewers get no session, but their links still run on user machines: only list apps and hosts you trust.