# Building custom agents from scratch

> **Note:** For new custom agents, run
> [`fullsend agent new <name>`](../../cli/agent.md#agent-new) — it generates a
> complete, valid, registered agent and is the recommended starting point. See
> [Bring Your Own Agent](bring-your-own-agent.md) for the full workflow. The
> patterns below are retained for understanding an existing hand-written agent.

This guide walks through creating a custom from-scratch agent on a per-repo
fullsend installation.

Before building from scratch, consider whether extending a default agent would
meet your needs. Start with the
[escalation ladder](../../agents/topics/escalation-ladder.md) to exhaust
lighter options first, and see
[Default, derived, and custom agents](../../agents/topics/default-vs-custom.md)
for the distinction and when each approach makes sense.

For the config-driven approach to building or configuring agents, see
[Bring Your Own Agent](bring-your-own-agent.md). For configuring existing agents (overriding harnesses, skills, or policies), see [Configuring agent behavior](customizing-agents.md).

## Prerequisites

- A GitHub repository with fullsend [installed](../getting-started/configuring-github.md).

## Architecture overview

A custom agent is composed of six parts:

```
.fullsend/
  agents/          # Agent prompt (Markdown with YAML frontmatter)
  harness/         # Execution config (sandbox image, host files, env vars)
  policies/        # Network and filesystem sandbox policies
  schemas/         # JSON Schema for validating agent output
  scripts/         # Pre/post scripts that run OUTSIDE the sandbox
  skills/          # Knowledge documents mounted into the sandbox
```

Register agents in `config.yaml` with a local `source:` path. For agents that extend a default, use `base:` composition to inherit from the upstream harness and override only what differs. See [Bring Your Own Agent](bring-your-own-agent.md) for the config-driven approach and [Harness Field Reference](../../reference/harness-reference.md) for the complete harness YAML reference.

The key security invariant: agents run inside an untrusted [sandbox](../../glossary.md#sandbox) with no credentials. Pre-scripts fetch data *before* the sandbox starts; post-scripts act on agent output *after* the sandbox exits. Agents never have direct write access to external systems. See the [security threat model](../../problems/security-threat-model.md) for the full trust model.

## Step 1: Write the agent prompt

Create `.fullsend/agents/my-agent.md`:

````markdown
---
name: my-agent
description: >-
  One-line description of what this agent does.
tools: Bash(gh,jq,curl,python3,find,ls,cat,head,grep,wc,tree)
model: opus
skills:
  - my-skill
disallowedTools: >-
  Bash(git push *), Bash(git push),
  Bash(gh issue create *), Bash(gh issue edit *)
---

# My Agent

You are a [role description]. Your job is to [purpose].

## Inputs

Environment variables set by the pre-script:

- `MY_INPUT_FILE` — path to input data JSON
- `TARGET_REPO_DIR` — path to target repository checkout

Environment variables set by the runner, present in every agent's shell:

- `FULLSEND_OUTPUT_DIR` — where to write your result
- `FULLSEND_TIMEOUT_MINUTES` — the harness's `timeout_minutes`, your whole budget
- `FULLSEND_ITERATION_DEADLINE` — Unix time (seconds) at which this iteration is killed;
  write your result before it (see [`fullsend run` § Budget and deadline](../../cli/run.md#budget-and-deadline))
- `TRACEPARENT` — W3C trace context of this iteration's agent span, so runtime telemetry
  can join the Fullsend trace; empty when telemetry produced no valid span context
  (see [`fullsend run` § Budget and deadline](../../cli/run.md#budget-and-deadline))

## Process

### Phase 1: Understand the input

```bash
echo "::notice::PHASE 1: Parse input"
cat "$MY_INPUT_FILE" | jq .
```

[Describe what the agent should extract and how to reason about it]

### Phase 2: Compose a greeting

Based on the issue content, compose a friendly greeting that:

- Acknowledges the issue author by mentioning what they reported
- Confirms the agent has read the issue
- Is concise (1-2 sentences)

### Phase 3: Write result

Write to `$FULLSEND_OUTPUT_DIR/agent-result.json`:

```json
{
  "status": "complete",
  "greeting": "Hello! I've reviewed your issue about [topic]. Thanks for the detailed report!"
}
```

## Constraints

- You do NOT write code, create issues, or modify anything.
  Your only output is the JSON result file.
- The JSON must be valid and parseable. No markdown fences.
- Keep the greeting under 280 characters.
````

### Key frontmatter fields

| Field | Purpose |
|-------|---------|
| `name` | Must match the filename (without `.md`) |
| `tools` | Bash commands the agent can run. Restrict to what's needed. |
| `model` | LLM model (`opus`, `sonnet`, etc.) |
| `skills` | [Skill](../../glossary.md#skill) directories to mount (relative to `skills/`) |
| `disallowedTools` | Bash patterns the agent is forbidden from running |

### Design principles

1. **Agent writes JSON, scripts do actions.** The agent's only output is a structured JSON file. All side effects (creating issues, posting comments, calling APIs) happen in post-scripts.

2. **Name specific things.** Don't say "add caching." Say "use `casbin` v2.82.0 from `go.mod` with the RBAC model adapter in `pkg/api/middleware/`."

3. **Confidence model.** Have the agent assess its own confidence and branch: act when confident, ask when uncertain.

## Step 2: Define the harness

Create `.fullsend/harness/my-agent.yaml`:

```yaml
agent: agents/my-agent.md
model: opus
effort: high                        # optional: low, medium, high, xhigh, max (claude runtime only)
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
policy: policies/my-agent.yaml
role: triage                        # a role the mint serves — not the agent's name (see Custom Agent Identity)

providers:
  - vertex-ai          # Required: model access (Anthropic API + GCP)
  - github             # GitHub API + Git transport

openshell:
  profiles:
    - profiles/fullsend-vertex-ai.yaml  # must be listed explicitly to be imported
    - profiles/fullsend-github.yaml

host_files:
  # GCP credentials for Vertex AI (required for model access)
  - src: env/gcp-vertex.env
    dest: /sandbox/workspace/.env.d/gcp-vertex.env
    expand: true
  - src: ${GOOGLE_APPLICATION_CREDENTIALS}
    dest: /tmp/.gcp-credentials.json
  - src: ${GCP_OIDC_TOKEN_FILE}
    dest: /sandbox/workspace/.gcp-oidc-token
    optional: true
  # Your custom input files (written by pre-script)
  - src: /tmp/workspace/my-input.json
    dest: /sandbox/workspace/my-input.json
    optional: true

skills:
  - skills/my-skill

pre_script: scripts/pre-my-agent.sh

validation_loop:
  script: scripts/validate-output-schema.sh
  schema: schemas/my-agent-result.schema.json
  max_iterations: 2

post_script: scripts/post-my-agent.sh

# Optional: give the sandbox a read-only token (default is write everywhere).
# privilege_levels:
#   runtime: read

env:
  runner:
    MY_VAR: "${MY_VAR}"
    ISSUE_KEY: "${ISSUE_KEY}"
    GH_TOKEN: "${GH_TOKEN}"  # auto-minted in CI when --mint-url is provided
    FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/my-agent-result.schema.json

timeout_minutes: 20

# Optional: enable runtime skill fetching
# allowed_remote_resources:
#   - https://github.com/org/skills/
# allow_runtime_fetch: true
# max_runtime_fetches: 10
```

See [Harness Field Reference](../../reference/harness-reference.md) for the full field reference (including optional `security`, `providers`, `plugins`, and runtime fetch blocks).

The key pattern to understand is how data flows into the sandbox through `host_files`:

1. **Pre-script** runs on the runner and writes files to `/tmp/workspace/`.
2. **Harness** copies those files into the sandbox via `host_files`.
3. **Agent** reads them inside the sandbox.

The agent never has direct access to credentials. The pre-script uses credentials to fetch data, writes it to a file, and the harness copies the file (not the credentials) into the sandbox.

## Step 3: Define the sandbox policy

The sandbox policy controls filesystem, process, and landlock restrictions. Network access is handled separately through provider profiles (see below).

Create `.fullsend/policies/my-agent.yaml`:

```yaml
version: 1
filesystem_policy:
  include_workdir: true
  read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log]
  read_write: [/sandbox, /tmp, /dev/null]
landlock:
  compatibility: best_effort
process:
  run_as_user: sandbox
  run_as_group: sandbox
```

Most custom agents can reuse the `policies/base.yaml` that [`fullsend agent new`](../../cli/agent.md#agent-new) writes — the same policy the fleet agents run under — and only write their own when they need different filesystem or process rules. Either way, commit the policy: CI does not supply one.

### Network access via providers (recommended)

The recommended way to grant network access is through provider profiles declared in the harness. Add a `providers` field to your harness YAML:

```yaml
providers:
  - vertex-ai       # Anthropic API + GCP (required for model access)
  - github           # GitHub API + Git transport
  - package-registries  # npm, PyPI, Go modules (optional)

openshell:
  profiles:
    - profiles/fullsend-vertex-ai.yaml
    - profiles/fullsend-github.yaml
    - profiles/fullsend-package-registries.yaml
```

Each provider has a profile that defines its endpoints and binaries. Every profile a provider needs must be listed under `openshell.profiles` (or inherited via `base:` composition) — the gateway only composes the profiles named there into the effective network policy, not every file that happens to exist under `profiles/`. This keeps endpoint definitions in one place and avoids copy-pasting network blocks across agents.

The scaffold ships with profiles for common services. To see what's available:

```bash
ls .fullsend/providers/     # provider definitions (name + type)
ls .fullsend/profiles/      # profile YAMLs (endpoints + binaries)
```

> **Note:** A profile YAML file in `profiles/` is **not** imported automatically by its presence alone. Only profiles listed in the harness under `openshell.profiles` (or resolved via base composition) are imported. To use a custom profile, add it to your harness's `openshell.profiles` list (e.g., `profiles/my-custom-profile.yaml`).

For services not covered by existing profiles, you can either create a custom profile or use inline `network_policies` in your policy YAML (both approaches work — composition is additive).

### Network access via inline policies (alternative)

You can also define network rules directly in the policy YAML. This is useful for one-off endpoints specific to a single agent:

```yaml
network_policies:
  my_service:
    name: my-service
    endpoints:
      - host: "api.example.com"
        port: 443
        protocol: rest
        enforcement: enforce
        access: read-only
    binaries:
      - path: "**/curl"
```

Inline rules and provider-composed rules coexist — composition is additive. If both define the same endpoint, the duplicate is harmless.

### Policy design principles

- **Vertex AI is always required** — the agent needs it to talk to the LLM. Use the `vertex-ai` provider.
- **Add network access only for what the agent needs.** If the agent doesn't need web search, don't allow it.
- **Use `binaries` to restrict which programs can access each endpoint.** This prevents the agent from using unexpected tools to exfiltrate data.
- **Prefer providers for shared services.** Use inline policies only for agent-specific endpoints.
- **Never allow APIs from the sandbox.** All network reads happen in pre-scripts; all network writes happen in post-scripts.

## Step 4: Define the output schema

Create `.fullsend/schemas/my-agent-result.schema.json`:

```json
{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "My Agent Result",
  "type": "object",
  "required": ["status", "greeting"],
  "properties": {
    "status": {
      "type": "string",
      "enum": ["complete", "needs_input", "error"]
    },
    "greeting": {
      "type": "string",
      "maxLength": 280
    }
  }
}
```

The schema is enforced by `validation_loop` in the harness. If the agent's output doesn't match, it's re-invoked with the validation error and asked to fix it.

## Step 5: Write pre and post scripts

### Pre-script (data fetching)

`.fullsend/scripts/pre-my-agent.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail

WORKSPACE="/tmp/workspace"
mkdir -p "$WORKSPACE"

if [[ "${ISSUE_SOURCE}" == "github" ]]; then
  gh issue view "$ISSUE_KEY" --repo "$REPO_FULL_NAME" \
    --json number,title,body,labels,comments \
    > "$WORKSPACE/my-input.json"
fi

echo "Pre-script complete."
```

The pre-script has full credentials on the trusted runner. It fetches data from external systems and writes it to files that the harness copies into the sandbox. Credentials never enter the sandbox.

#### Skipping the run from the pre-script

A pre-script can tell `fullsend run` not to start the agent at all — useful when
an open PR already addresses the issue, or when the work is otherwise
redundant. `fullsend run` exports `FULLSEND_PRESCRIPT_OUTPUT` (a file path); the
script appends `skipped=true` to it and the run reports a ⏭️ skipped status and
exits 0 **before the sandbox is created**.

```bash
if [[ -n "${FULLSEND_PRESCRIPT_OUTPUT:-}" ]]; then
  {
    echo "skipped=true"
    echo "reason=PR #${EXISTING_PR} already addresses this issue"
  } >> "${FULLSEND_PRESCRIPT_OUTPUT}"
  exit 0
fi
```

Always guard on the variable being set — older `fullsend` versions do not export
it, and appending to an empty path would fail the script. Malformed content is a
hard error rather than a silent proceed, so a mistyped skip cannot turn into a
duplicate agent run. See the [pre-script output v1 contract](../../normative/prescript-output/v1/README.md)
for the full grammar, error semantics, and CI relay behavior.

**Exit code 78 — neutral skip.** For simple cases, a pre-script can skip the
run by exiting with code 78 instead of writing to the output file. The last
non-empty line of stdout becomes the skip reason:

```bash
# Scheduled agent: check if there's work to do
PENDING=$(gh api ... --jq 'length')
if [[ "${PENDING}" -eq 0 ]]; then
  echo "No issues need scoring"
  exit 78
fi
```

Exit 78 and the output-file protocol can be combined — the output file is
still parsed for `reason` and other outputs when exit 78 is used, but a parse
error does not block the skip. See the
[normative spec](../../normative/prescript-output/v1/README.md#exit-code-78--neutral-skip)
for full details.

**Hard failures.** A non-zero exit other than 78 fails the run. Print a GitHub
Actions error annotation so the message appears in the PR status comment
instead of a bare `exit status 1`:

```bash
echo "::error::Fix iteration ${ITERATION} exceeds bot cap of ${CAP}. Escalating to human."
exit 1
```

Without an annotation, the last non-empty stderr line (then stdout) is used.
See the [normative spec](../../normative/prescript-output/v1/README.md#hard-failure-diagnostics)
for the full preference order and sanitization rules.

### Post-script (action execution)

`.fullsend/scripts/post-my-agent.sh`:

```bash
#!/usr/bin/env bash
set -euo pipefail

# Prefer the validated iteration directory set by the harness
# (FULLSEND_VALIDATED_ITERATION_DIR, always an absolute path) — without
# it, scanning for the last iteration can pick up output that failed
# validation. Fall back to scanning for the last iteration for
# harnesses with no validation_loop.
if [[ -n "${FULLSEND_VALIDATED_ITERATION_DIR:-}" ]]; then
  RESULT_FILE="${FULLSEND_VALIDATED_ITERATION_DIR}/agent-result.json"
else
  RESULT_FILE=""
  for dir in iteration-*/output; do
    if [[ -f "${dir}/agent-result.json" ]]; then
      RESULT_FILE="${dir}/agent-result.json"
    fi
  done
fi

if [[ -z "${RESULT_FILE}" ]] || [[ ! -f "${RESULT_FILE}" ]]; then
  echo "ERROR: agent-result.json not found"
  exit 1
fi

# Validate JSON structure before extracting fields.
# The agent runs in an untrusted sandbox — treat its output as untrusted input.
if ! jq empty "${RESULT_FILE}" 2>/dev/null; then
  echo "ERROR: agent-result.json is not valid JSON"
  exit 1
fi

STATUS=$(jq -r '.status // ""' "${RESULT_FILE}")
GREETING=$(jq -r '.greeting // ""' "${RESULT_FILE}")

# Validate status against known values before acting on it.
case "${STATUS}" in
  complete)
    echo "Agent completed successfully"
    ;;
  needs_input)
    echo "Agent needs more information"
    ;;
  *)
    echo "ERROR: Unknown or missing status '${STATUS}'"
    exit 1
    ;;
esac

# Post the greeting as a comment on the issue.
if [[ -n "${GREETING}" && "${STATUS}" == "complete" ]]; then
  gh issue comment "${ISSUE_KEY}" --repo "${REPO_FULL_NAME}" --body "${GREETING}"
  echo "Posted greeting to issue #${ISSUE_KEY}"
fi
```

### Post-script security considerations

The post-script runs on the trusted runner with full credentials, but reads output produced by the untrusted sandbox. Treat agent output as untrusted input:

- **Validate JSON structure** before extracting fields (`jq empty` catches malformed output).
- **Validate field values against an allowlist** (the `case` statement above) rather than passing them to shell commands or APIs unchecked.
- **Never interpolate agent output into shell commands** without quoting. Use `jq -r` to extract values into variables, then use `"${VAR}"` (double-quoted) everywhere.
- **Limit string lengths** in the JSON schema (`maxLength`) to prevent resource exhaustion when posting to external APIs.

## Step 6: Create skills (optional)

[Skills](../../glossary.md#skill) are Markdown documents mounted into the sandbox that provide domain knowledge the agent can reference. See [Configuring Agent Behavior — Adding a skill](customizing-agents.md#adding-a-skill) for how to create one.

Place your skill at `.fullsend/skills/my-skill/SKILL.md`, then reference it in both the agent frontmatter (`skills: [my-skill]`) and the harness (`skills: [skills/my-skill]`).

## Step 7: Create the GitHub Actions workflow

Create `.github/workflows/my-agent.yml`:

```yaml
name: fullsend-my-agent

permissions:
  contents: read
  id-token: write
  issues: write

on:
  workflow_dispatch:
    inputs:
      issue_key:
        description: 'Issue key'
        required: true
        type: string
      issue_source:
        description: 'Issue source: github'
        required: true
        type: string
        default: 'github'

concurrency:
  group: my-agent-${{ inputs.issue_key || 'unknown' }}
  cancel-in-progress: true

jobs:
  run:
    runs-on: ubuntu-24.04
    steps:
      - name: Checkout repository for harness reading
        uses: actions/checkout@v6

      - name: Checkout target repo
        uses: actions/checkout@v6
        with:
          path: target-repo

      - name: Checkout upstream defaults
        uses: actions/checkout@v6
        with:
          repository: fullsend-ai/fullsend
          ref: main
          path: .defaults
          sparse-checkout: |
            internal/scaffold/fullsend-repo/

      - name: Prepare workspace (upstream defaults)
        run: |
          set -euo pipefail
          SRC=".defaults/internal/scaffold/fullsend-repo"
          # Layer the scaffold's provider definitions so the providers
          # configured in Step 2 resolve without vendoring copies into this
          # repository. The policy (Step 3) and profiles are committed with
          # the harness; this step layers neither.
          LAYERED_DIRS="providers scripts"
          for dir in ${LAYERED_DIRS}; do
            if [[ -d "${SRC}/${dir}" ]]; then
              mkdir -p ".fullsend/${dir}"
              cp -r "${SRC}/${dir}/." ".fullsend/${dir}/"
            fi
          done
          rm -rf .defaults

      - name: Install fullsend CLI
        uses: fullsend-ai/fullsend@main
        with:
          agent: __install_only__

      - name: Run my-agent
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_KEY: ${{ inputs.issue_key }}
          ISSUE_SOURCE: ${{ inputs.issue_source || 'github' }}
          REPO_FULL_NAME: ${{ github.repository }}
          FULLSEND_GCP_WIF_PROVIDER: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
          FULLSEND_GCP_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
          ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
          CLOUD_ML_REGION: ${{ vars.FULLSEND_GCP_REGION }}  # value drift is detected and repaired by convergence
        run: |
          set -euo pipefail
          mkdir -p "$GITHUB_WORKSPACE/output"
          fullsend run my-agent \
            --fullsend-dir "$GITHUB_WORKSPACE/.fullsend" \
            --target-repo "$GITHUB_WORKSPACE/target-repo" \
            --output-dir "$GITHUB_WORKSPACE/output"

      - name: Upload artifacts
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: fullsend-my-agent
          path: ${{ github.workspace }}/output
```

This example reflects the currently deployed cancellation policy. Under
[ADR 0106](../../ADRs/0106-serialize-agent-runs-and-coalesce-subsequent-events.md),
the platform will change subject-scoped agent workflows to
`cancel-in-progress: false` once preserve-and-coalesce scheduling is
implemented. Until that migration lands, keep the setting aligned with the
reusable workflow that invokes the agent.

### Critical workflow steps

1. **Checkout target repo** — `fullsend run` requires `--target-repo` pointing to a separate checkout of the repository the agent will work on. Without this, fullsend may overwrite output files.

2. **Prepare workspace (upstream defaults)** — the fullsend CLI expects files in `.fullsend/harness/`, `.fullsend/agents/`, etc. The preparation step copies upstream default scripts into the workspace.

3. **Authenticate to GCP via WIF** — provides short-lived credentials for Vertex AI. Uses Workload Identity Federation (no service account keys).

4. **Prepare sandbox credentials** — the WIF auth creates a credential config that references GitHub's OIDC endpoint, which isn't reachable from inside the sandbox. This script pre-fetches the OIDC token and rewrites the config to use a file-based source.

5. **`ANTHROPIC_VERTEX_PROJECT_ID` and `CLOUD_ML_REGION`** — must be in the workflow `env` block so the `gcp-vertex.env` file (copied into the sandbox with `expand: true`) resolves correctly.

6. **All `env.runner` variables** must appear in the workflow `env` block. If your harness references `MY_VAR: "${MY_VAR}"`, the workflow must set `MY_VAR`.

### Bringing your own identity

To make the agent comment as an application other than `github-actions` create a new application,
install it in your repository and generate a key for it. Then upload it alongside the app id
to your repo:

```bash
gh secret set MY_AGENT_APP_PRIVATE_KEY --repo OWNER/REPO < path/to/private-key.pem
gh variable set MY_AGENT_APP_ID --repo OWNER/REPO --body "123456"
```

Next modify the workflow you create to run `fullsend run` to add a new step:

```yaml
- name: Generate app token
  id: app-token
  uses: actions/create-github-app-token@v3
  with:
    app-id: ${{ vars.MY_AGENT_APP_ID }}
    private-key: ${{ secrets.MY_AGENT_APP_PRIVATE_KEY }}
    repositories: ${{ github.event.repository.name }}
```

And then modify where the `GH_TOKEN` variable comes from:

```yaml
- name: Run my-agent
  env:
    GH_TOKEN: ${{ steps.app-token.outputs.token }}
    ...
```

**Note**: if your agent creates commits, add a step that runs:

```bash
git config --global user.name "my-agent[bot]"
git config --global user.email "${{ vars.MY_AGENT_APP_ID }}+my-agent[bot]@users.noreply.github.com"
```

**Note**: in order to distribute the identity you need to share the PEM and the ID
or setup a custom mint for your identities. See [Standalone Mint](../infrastructure/standalone-mint.md)
for more information.

## Step 8: Trigger the agent

The workflow above uses `workflow_dispatch`, which means you trigger it manually:

- **From the GitHub UI:** Actions → fullsend-my-agent → Run workflow → fill in `issue_key` and `issue_source`.
- **From the CLI:** `gh workflow run my-agent.yml -f issue_key=123 -f issue_source=github`

### Slash-command dispatch (optional)

If you want slash-command triggers (e.g., `/my-command` on a GitHub issue), create a dispatch workflow. This requires adding `actions: write` and `issues: write` permissions:

```yaml
name: my-agent-dispatch

permissions:
  actions: write
  contents: read
  issues: write

on:
  issue_comment:
    types: [created]

jobs:
  dispatch:
    if: >-
      github.event.comment.user.type != 'Bot'
      && startsWith(github.event.comment.body, '/my-command')
    runs-on: ubuntu-24.04
    steps:
      - name: Dispatch
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          ISSUE_NUMBER: ${{ github.event.issue.number }}
          REPO: ${{ github.repository }}
        run: |
          gh workflow run my-agent.yml \
            --repo "$REPO" \
            -f issue_key="$ISSUE_NUMBER" \
            -f issue_source="github"

          gh api "repos/${REPO}/issues/comments/${{ github.event.comment.id }}/reactions" \
            -f content="rocket" --silent 2>/dev/null || true
```

## Quick troubleshooting

| Symptom | Likely cause |
|---------|-------------|
| Agent crashes immediately (0s runtime) | Sandbox can't authenticate to Vertex AI. Verify `ANTHROPIC_VERTEX_PROJECT_ID`, `CLOUD_ML_REGION`, and that `prepare-sandbox-credentials.sh` ran after the WIF auth step. |
| "Harness file not found" | The fullsend CLI looks for `.fullsend/harness/my-agent.yaml`. Verify the file exists and the agent is registered in `config.yaml`. |
| Agent can't find input files | Ensure pre-script output paths match `host_files` entries in the harness. |
| Network policy blocks requests | Check `openshell-sandbox.log` in artifacts for `DENIED` entries. Add the endpoint to the policy. |
| Schema validation fails twice | Check the agent transcript in artifacts to see what it produced vs. what the schema expected. |

## File checklist

When creating a new agent, you need these files:

```
.fullsend/
  config.yaml                            # Agent registration
  agents/my-agent.md                     # Agent prompt
  harness/my-agent.yaml                  # Execution config
  policies/my-agent.yaml                 # Sandbox policy
  schemas/my-agent-result.schema.json    # Output validation
  scripts/pre-my-agent.sh                # Data fetching
  scripts/post-my-agent.sh               # Action execution
  skills/my-skill/SKILL.md               # Domain knowledge (optional)

.github/workflows/
  my-agent.yml                           # GitHub Actions workflow
  my-agent-dispatch.yml                  # Slash command trigger (optional)
```

## Reference

- [Configuring agent behavior](customizing-agents.md) — configure existing agent harnesses, skills, and policies
- [Bugfix workflow](bugfix-workflow.md) — how the built-in agents work together end to end
- [Getting Started](../getting-started/README.md) — prerequisite: admin setup guide
- [Architecture overview](../../architecture.md) — component vocabulary and execution stack
- [Security threat model](../../problems/security-threat-model.md) — how fullsend thinks about security
