Skip to content
By Greg Allen, Wayne Sun, Ralph Bean, Adam Scerra, Hector Martinez, Marta Anon, Barak Korren, Hector Martinez Lopez, Shai Revivo
Picture of Greg Allen
Greg Allen
Picture of Wayne Sun
Wayne Sun
Picture of Ralph Bean
Ralph Bean
Picture of Adam Scerra
Adam Scerra
Picture of Hector Martinez
Hector Martinez
Picture of Marta Anon
Marta Anon
Picture of Barak Korren
Barak Korren
Picture of Hector Martinez Lopez
Hector Martinez Lopez
Picture of Shai Revivo
Shai Revivo

Building custom agents from scratch

Note: For new custom agents, run fullsend agent new <name> — it generates a complete, valid, registered agent and is the recommended starting point. See Bring Your Own Agent 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 to exhaust lighter options first, and see Default, derived, and custom agents for the distinction and when each approach makes sense.

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

Prerequisites

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 for the config-driven approach and Harness Field Reference for the complete harness YAML reference.

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

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

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

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 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 for full details.

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) — 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 are Markdown documents mounted into the sandbox that provide domain knowledge the agent can reference. See Configuring Agent Behavior — 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"
          # Agent files (harness, agents, policies, etc.) are now resolved
          # from the fullsend-ai/agents repo at runtime by `fullsend run`.
          # Only infrastructure scripts remain in the scaffold.
          LAYERED_DIRS="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: 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@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 }}
          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, 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 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) — 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 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. Verify the file exists and the agent is registered in config.yaml.
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 DENIED 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/
  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