---
title: Hooks
description: Write sandboxed agent-qa hooks that prepare data, verify side effects, and export runtime variables back to tests and suites.
---



Hooks are project scripts that agent-qa runs in a Docker sandbox. Use them when a test needs setup data, API verification, cleanup, or a runtime value that should be captured before a browser or mobile step runs.

The full field reference lives in [Hook](/docs/agent-qa/configuration/hook). This guide focuses on writing one hook and using it from tests or suites.

## Register a hook [#register-a-hook]

`workspace.hooksFile` points agent-qa at a hook registry, usually `hooks.yaml`.

```yaml
workspace:
  hooksFile: hooks.yaml
```

The hook registry contains a `hooks` list. A hook needs an `id`, `name`, `runtime`, `file`, and `timeout`. `network` defaults to `true`; set it explicitly when the hook calls an external or local API.

```yaml
hooks:
  - id: h_aster-bloom-cloud-drift-ember-field-glade-hollow-ivory-jasper
    name: Fetch first Hacker News story
    runtime: node
    file: scripts/fetch-hn-top-story.mjs
    timeout: 30s
    network: true
```

Supported runtimes are `node`, `bun`, `python`, and `bash`.

## Fetch the Hacker News top story [#fetch-the-hacker-news-top-story]

`agent-qa init` currently generates the Node version of this Hacker News hook for web projects. The Bun, Python, and Bash examples below implement the same hook contract: fetch top stories, validate the first story and title, then write `HN_FIRST_STORY_TITLE` and `HN_FIRST_STORY_ID` to `/tmp/agent-qa.env`.

<CodeTabs defaultValue="node">
  <CodeTab value="node" label="Node">
    ```js
    // scripts/fetch-hn-top-story.mjs
    import { writeFile } from "node:fs/promises"

    const topStoriesUrl = "https://hacker-news.firebaseio.com/v0/topstories.json"

    async function getJson(url) {
      const response = await fetch(url)
      if (!response.ok) {
        throw new Error(`HN API request failed: ${response.status} ${response.statusText}`)
      }
      return response.json()
    }

    function escapeEnvValue(value) {
      return String(value)
        .replace(/\r?\n/g, " ")
        .replace(/\\/g, "\\\\")
        .replace(/"/g, '\\"')
    }

    const storyIds = await getJson(topStoriesUrl)
    const firstStoryId = Array.isArray(storyIds) ? storyIds[0] : undefined
    if (!Number.isInteger(firstStoryId)) {
      throw new Error("HN API returned no first story id")
    }

    const story = await getJson(`https://hacker-news.firebaseio.com/v0/item/${firstStoryId}.json`)
    const title = typeof story?.title === "string" ? story.title.trim() : ""
    if (!title) {
      throw new Error(`HN item ${firstStoryId} returned no title`)
    }

    await writeFile("/tmp/agent-qa.env", [
      `HN_FIRST_STORY_TITLE="${escapeEnvValue(title)}"`,
      `HN_FIRST_STORY_ID=${firstStoryId}`,
      "",
    ].join("\n"), "utf-8")
    ```
  </CodeTab>

  <CodeTab value="bun" label="Bun">
    ```js
    // scripts/fetch-hn-top-story.js
    const topStoriesUrl = "https://hacker-news.firebaseio.com/v0/topstories.json"

    async function getJson(url) {
      const response = await fetch(url)
      if (!response.ok) {
        throw new Error(`HN API request failed: ${response.status} ${response.statusText}`)
      }
      return response.json()
    }

    function escapeEnvValue(value) {
      return String(value)
        .replace(/\r?\n/g, " ")
        .replace(/\\/g, "\\\\")
        .replace(/"/g, '\\"')
    }

    const storyIds = await getJson(topStoriesUrl)
    const firstStoryId = Array.isArray(storyIds) ? storyIds[0] : undefined
    if (!Number.isInteger(firstStoryId)) {
      throw new Error("HN API returned no first story id")
    }

    const story = await getJson(`https://hacker-news.firebaseio.com/v0/item/${firstStoryId}.json`)
    const title = typeof story?.title === "string" ? story.title.trim() : ""
    if (!title) {
      throw new Error(`HN item ${firstStoryId} returned no title`)
    }

    await Bun.write("/tmp/agent-qa.env", [
      `HN_FIRST_STORY_TITLE="${escapeEnvValue(title)}"`,
      `HN_FIRST_STORY_ID=${firstStoryId}`,
      "",
    ].join("\n"))
    ```
  </CodeTab>

  <CodeTab value="python" label="Python">
    ```python
    # scripts/fetch_hn_top_story.py
    import json
    import urllib.request

    TOP_STORIES_URL = "https://hacker-news.firebaseio.com/v0/topstories.json"


    def get_json(url):
        request = urllib.request.Request(url, headers={"User-Agent": "agent-qa-hook"})
        with urllib.request.urlopen(request, timeout=20) as response:
            if response.status < 200 or response.status >= 300:
                raise RuntimeError(f"HN API request failed: {response.status}")
            return json.load(response)


    def escape_env_value(value):
        return str(value).replace("\r", " ").replace("\n", " ").replace("\\", "\\\\").replace('"', '\\"')


    story_ids = get_json(TOP_STORIES_URL)
    first_story_id = story_ids[0] if isinstance(story_ids, list) and story_ids else None
    if not isinstance(first_story_id, int):
        raise RuntimeError("HN API returned no first story id")

    story = get_json(f"https://hacker-news.firebaseio.com/v0/item/{first_story_id}.json")
    title = story.get("title", "").strip() if isinstance(story, dict) else ""
    if not title:
        raise RuntimeError(f"HN item {first_story_id} returned no title")

    with open("/tmp/agent-qa.env", "w", encoding="utf-8") as env_file:
        env_file.write(f'HN_FIRST_STORY_TITLE="{escape_env_value(title)}"\n')
        env_file.write(f"HN_FIRST_STORY_ID={first_story_id}\n")
    ```
  </CodeTab>

  <CodeTab value="bash" label="Bash">
    ```bash
    #!/usr/bin/env bash
    set -euo pipefail

    top_stories_url="https://hacker-news.firebaseio.com/v0/topstories.json"

    first_story_id="$(curl -fsSL "$top_stories_url" | jq '.[0]')"
    if ! [[ "$first_story_id" =~ ^[0-9]+$ ]]; then
      echo "HN API returned no first story id" >&2
      exit 1
    fi

    story_json="$(curl -fsSL "https://hacker-news.firebaseio.com/v0/item/${first_story_id}.json")"
    title="$(printf '%s' "$story_json" | jq -r '.title // ""')"
    if [[ -z "$title" || "$title" == "null" ]]; then
      echo "HN item ${first_story_id} returned no title" >&2
      exit 1
    fi

    escape_env_value() {
      printf '%s' "$1" | tr '\r\n' ' ' | sed 's/\\/\\\\/g; s/"/\\"/g'
    }

    {
      printf 'HN_FIRST_STORY_TITLE="%s"\n' "$(escape_env_value "$title")"
      printf 'HN_FIRST_STORY_ID=%s\n' "$first_story_id"
    } > /tmp/agent-qa.env
    ```
  </CodeTab>
</CodeTabs>

## Use hooks from tests and suites [#use-hooks-from-tests-and-suites]

Use `setup` when the hook should run before the test or suite starts.

```yaml
setup:
  - h_aster-bloom-cloud-drift-ember-field-glade-hollow-ivory-jasper
steps:
  - Navigate to "https://news.ycombinator.com/"
  - Verify the page shows "{{env:HN_FIRST_STORY_TITLE}}"
```

Use `teardown` for cleanup hooks that should run after the test or suite.

```yaml
teardown:
  - h_update-borg-artha-any-packet-derive-torch-front-plied-bed
```

Use inline `runHook` syntax when the hook belongs at one specific step.

```yaml
steps:
  - Run the HN lookup hook {{runHook:"h_aster-bloom-cloud-drift-ember-field-glade-hollow-ivory-jasper"}}.
  - Verify the first story id is "{{env:HN_FIRST_STORY_ID}}".
```

Inline hooks run before variable interpolation for that step. Variables exported by the hook are available to later steps.

## Export variables [#export-variables]

To return values to agent-qa, write a dotenv file at `/tmp/agent-qa.env`.

```dotenv
HN_FIRST_STORY_TITLE="Launch HN: Example"
HN_FIRST_STORY_ID=123456
```

After the container exits, agent-qa reads that file and merges the variables into the run. A later successful hook can override a variable exported by an earlier hook.

## Add dependencies when needed [#add-dependencies-when-needed]

`deps` copies extra files into the sandbox beside the hook entry file. `packageFile` copies a package file when the runtime needs package metadata.

```yaml
hooks:
  - id: h_aster-bloom-cloud-drift-ember-field-glade-hollow-ivory-jasper
    name: Fetch first Hacker News story
    runtime: node
    file: scripts/fetch-hn-top-story.mjs
    deps:
      - scripts/hn-client.mjs
    packageFile: package.json
    timeout: 30s
    network: true
```

Keep dependencies small. Files are copied into the temporary workspace for the hook run, not mounted from the repository.

## Read active web auth state [#read-active-web-auth-state]

Authenticated web runs can expose the selected auth state to setup, inline, and teardown hooks. See [Auth state](/docs/agent-qa/guides/auth-state) for the `AGENT_QA_AUTH_STATE_JSON` and `AGENT_QA_AUTH_STATE_STORAGE_STATE_PATH` contract, plus Bash, Python, Node, and Bun examples that parse the raw storage-state JSON without Playwright.

## Sandbox environment [#sandbox-environment]

agent-qa runs hooks with Docker using the runtime image for the hook.

* The hook entry file, `deps`, and optional `packageFile` are copied into a temporary host directory.
* That directory is mounted at `/workspace`, and the hook command runs with `/workspace` as the working directory.
* The container filesystem is read-only.
* `/tmp` is writable and is where `/tmp/agent-qa.env` is written.
* Resource limits are applied with `--memory 512m`, `--cpus 1`, and `--pids-limit 256`.
* Environment variables from env files, CLI variables, previous successful hooks, and configured secrets are injected into the container.
* Known secret values are redacted from hook stdout, stderr, and errors. Variables written to `/tmp/agent-qa.env` are filtered when their value exactly matches a known secret.

Network access is enabled by default. Set `network: false` when the hook must not call the network.

```yaml
hooks:
  - id: h_aster-bloom-cloud-drift-ember-field-glade-hollow-ivory-jasper
    name: Validate exported HN variables offline
    runtime: bash
    file: scripts/validate-hn-env.sh
    timeout: 10s
    network: false
```

When `network: false` is set, agent-qa passes `--network none` to Docker for that hook container.

## Runtime images [#runtime-images]

The current hook runner images are published under the `vostride` Docker Hub namespace:

* [`vostride/agent-qa-hook-runner-node`](https://hub.docker.com/r/vostride/agent-qa-hook-runner-node)
* [`vostride/agent-qa-hook-runner-bun`](https://hub.docker.com/r/vostride/agent-qa-hook-runner-bun)
* [`vostride/agent-qa-hook-runner-python`](https://hub.docker.com/r/vostride/agent-qa-hook-runner-python)
* [`vostride/agent-qa-hook-runner-bash`](https://hub.docker.com/r/vostride/agent-qa-hook-runner-bash)
