Skip to content

Building custom agents

Deprecated: This guide uses the customized/ directory overlay, which is deprecated per ADR-0064. For new custom agents, register them in config.yaml with a local source: path instead. Run fullsend agent migrate-customizations --dry-run to preview migrating existing customizations.

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

For customizing existing agents (overriding harnesses, skills, or policies), see Customizing agents.

Prerequisites

Architecture overview

A custom agent is composed of six parts:

.fullsend/customized/
  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

At build time, the workflow layers these customized files on top of the upstream fullsend defaults. Your files override the defaults — anything you don't customize uses the standard fullsend configuration. See Customizing agents — Layered Configuration Resolution for details on how the layering works.

The key security invariant: agents run inside an untrusted 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 for the full trust model.

Step 1: Write the agent prompt

Create .fullsend/customized/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
- `FULLSEND_OUTPUT_DIR` — where to write your result

## 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

FieldPurpose
nameMust match the filename (without .md)
toolsBash commands the agent can run. Restrict to what's needed.
modelLLM model (opus, sonnet, etc.)
skillsSkill directories to mount (relative to customized/skills/)
disallowedToolsBash 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/customized/harness/my-agent.yaml:

yaml
agent: customized/agents/my-agent.md
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
policy: customized/policies/my-agent.yaml
role: my-agent

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

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:
  - customized/skills/my-skill

pre_script: customized/scripts/pre-my-agent.sh

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

post_script: customized/scripts/post-my-agent.sh

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}/customized/schemas/my-agent-result.schema.json

timeout_minutes: 20

# Optional: enable runtime skill fetching (ADR-0038 Phase 4)
# allowed_remote_resources:
#   - https://github.com/org/skills/
# allow_runtime_fetch: true
# max_runtime_fetches: 10

See Customizing agents — Harness YAML Structure 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/customized/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 scaffold's policies/base.yaml instead of creating their own. Override only when your agent has specific filesystem or process requirements.

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)

Each provider has a profile that defines its endpoints and binaries. When the sandbox starts, the gateway composes these profiles into the effective network policy automatically. 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)

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/customized/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/customized/scripts/pre-my-agent.sh:

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

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

elif [[ "${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.

Post-script (action execution)

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

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

RESULT_FILE=""
for dir in iteration-*/output; do
  if [[ -f "${dir}/agent-result.json" ]]; then
    RESULT_FILE="${dir}/agent-result.json"
  fi
done

if [[ -z "${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 are Markdown documents mounted into the sandbox that provide domain knowledge the agent can reference. See Customizing agents — Adding a Custom Skill for how to create one.

Place your skill at .fullsend/customized/skills/my-skill/SKILL.md, then reference it in both the agent frontmatter (skills: [my-skill]) and the harness (skills: [customized/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

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'

permissions:
  contents: read
  issues: write

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: v0
          path: .defaults
          sparse-checkout: |
            internal/scaffold/fullsend-repo/

      - name: Prepare workspace (upstream defaults + repo overrides)
        run: |
          set -euo pipefail
          SRC=".defaults/internal/scaffold/fullsend-repo"
          LAYERED_DIRS="agents skills schemas harness policies scripts env"
          for dir in ${LAYERED_DIRS}; do
            if [[ -d "${SRC}/${dir}" ]]; then
              mkdir -p ".fullsend/${dir}"
              cp -r "${SRC}/${dir}/." ".fullsend/${dir}/"
            fi
          done
          for dir in ${LAYERED_DIRS}; do
            if [[ -d ".fullsend/customized/${dir}" ]]; then
              find ".fullsend/customized/${dir}" -type f ! -name '.gitkeep' -print0 \
                | while IFS= read -r -d '' f; do
                    rel="${f#".fullsend/customized/"}"
                    mkdir -p ".fullsend/$(dirname "${rel}")"
                    cp "${f}" ".fullsend/${rel}"
                  done
            fi
          done
          rm -rf .defaults

      - name: Authenticate to GCP via WIF
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.FULLSEND_GCP_WIF_PROVIDER }}
          project_id: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}

      - name: Prepare sandbox credentials
        run: bash .fullsend/scripts/prepare-sandbox-credentials.sh

      - name: Install fullsend CLI
        uses: fullsend-ai/fullsend@v0
        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 }}
          ANTHROPIC_VERTEX_PROJECT_ID: ${{ secrets.FULLSEND_GCP_PROJECT_ID }}
          CLOUD_ML_REGION: ${{ vars.FULLSEND_GCP_REGION }}
        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

Critical workflow steps

  1. Checkout target repofullsend 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 + repo overrides) — the fullsend CLI expects files in .fullsend/harness/, .fullsend/agents/, etc. (not .fullsend/customized/). The layering step copies upstream defaults first, then overlays your customizations on top.

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

SymptomLikely 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, not customized/. Verify the "Prepare workspace" step is layering files correctly.
Agent can't find input filesEnsure pre-script output paths match host_files entries in the harness.
Network policy blocks requestsCheck openshell-sandbox.log in artifacts for BLOCKED entries. Add the endpoint to the policy.
Schema validation fails twiceCheck 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/customized/
  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