Skip to content

Bring Your Own Agent

Add a custom agent to fullsend — or change the configuration of an existing one — from harness file to CI.

This guide covers the end-to-end workflow for building and registering agents. For details on harness YAML structure and layered resolution, see Customizing agents.

This guide uses the fullsend-ai/agents triage agent as a running example.

How agents work

A fullsend agent has two parts:

  1. Harness file (YAML) — how the agent runs: sandbox image, policy, scripts, skills, credentials, timeouts.
  2. Agent definition (Markdown) — what the agent does: prompt, tools, model, skills.

The harness is the entry point. fullsend run triage reads the harness, provisions a sandbox, and launches the agent inside it.

fullsend run triage


┌── harness/triage.yaml ─────────┐
│  agent: agents/triage.md        │  ◄── prompt & tools
│  policy: policies/triage.yaml   │  ◄── sandbox rules
│  skills: [issue-labels]         │  ◄── domain knowledge
│  pre_script: scripts/pre-...    │  ◄── fetch data (before sandbox)
│  post_script: scripts/post-...  │  ◄── act on output (after sandbox)
└─────────────────────────────────┘

Security model: agents run inside a sandboxed environment. The sandbox policy (policies/base.yaml) enforces filesystem access, landlock, and process identity. Network access is provided entirely by provider profiles (YAML files in a providers/ directory) that are referenced by name in the harness providers: list — the policy file itself contains no network rules (see ADR 0065). Pre-scripts run on the trusted runner before the sandbox starts; post-scripts run after it exits.

Minimum viable agent

You need two files — a harness and an agent definition. For the sandbox policy, start with policies/base.yaml (the shared policy used by all built-in agents). Provider profiles supply network access:

my-agent-repo/
├── harness/my-agent.yaml       # Execution config
├── providers/vertex-ai.yaml    # Inference provider profile
└── agents/my-agent.md          # Agent prompt

harness/my-agent.yaml:

yaml
agent: agents/my-agent.md
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
policy: policies/base.yaml
providers:
  - vertex-ai
role: my-agent
slug: fullsend-ai-my-agent
timeout_minutes: 15

agents/my-agent.md:

markdown
---
name: my-agent
description: One-line description of what this agent does.
tools: Bash(gh,jq)
model: opus
---

You are my-agent. Your job is to [task description].

## Steps
1. Fetch input from environment variables
2. Analyze and process
3. Write JSON result to `$FULLSEND_OUTPUT_DIR/agent-result.json`

Do NOT push code, create issues, or modify anything directly.
Your only output is the JSON result file.

Network access (which APIs the agent can reach) is controlled by provider profiles, not by the policy file. The six built-in provider profiles (vertex-ai, github, github-ro, github-artifacts, gitleaks, package-registries) live in the providers/ directory — reference them by name in the harness providers: list. If you need access to an additional API, create a new provider profile YAML file with name, type, and credentials fields.

Real-world example: the triage agent

The fullsend-ai/agents triage agent is a full production agent. Here's its harness:

yaml
agent: agents/triage.md
doc: docs/agents/triage.md
model: opus
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
policy: policies/base.yaml
providers:
  - vertex-ai
  - github

role: triage
slug: fullsend-ai-triage

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

skills:
  - skills/issue-labels

pre_script: scripts/pre-triage.sh
post_script: scripts/post-triage.sh

validation_loop:
  script: scripts/validate-output-schema.sh
  max_iterations: 2

env:
  runner:
    FULLSEND_OUTPUT_SCHEMA: ${FULLSEND_DIR}/schemas/triage-result.schema.json

forge:
  github:
    pre_script: scripts/pre-triage.sh
    post_script: scripts/post-triage.sh
    env:
      runner:
        GITHUB_ISSUE_URL: ${GITHUB_ISSUE_URL}
        GH_TOKEN: ${GH_TOKEN}
      sandbox:
        GITHUB_ISSUE_URL: "${GITHUB_ISSUE_URL}"
        GH_TOKEN: "${GH_TOKEN}"

timeout_minutes: 10

Key patterns to note:

  • policy: policies/base.yaml is the shared base policy for all agents — it controls filesystem access and process identity. Network access comes from the providers: list.
  • providers: [vertex-ai, github] loads the corresponding YAML files from the providers/ directory. These profiles define which external APIs the sandbox can reach.
  • host_files copy credentials from the trusted runner into the sandbox. expand: true resolves ${VAR} references before copying.
  • forge.github scopes scripts and env vars to GitHub. When running on GitLab, a forge.gitlab block would take effect instead.
  • validation_loop re-invokes the agent if its JSON output fails schema validation.
  • env/gcp-vertex.env is referenced by relative path because both files live in the same repo. If your agent lives in a different repo, reference it by URL (see Remote references) or copy it locally.

Harness field reference

yaml
# ── Required ──────────────────────────────────────────────────
agent: agents/my-agent.md           # Path to agent definition
role: my-agent                      # Role name (a-z, 0-9, _, -)

# ── Identity & metadata ──────────────────────────────────────
slug: fullsend-ai-coder             # GitHub App credential slug
description: One-line summary       # Human-readable description
doc: docs/agents/my-agent.md        # Source-repo-only; not resolved at runtime
trigger: "event.type == 'issue'"    # Optional CEL expression over normevent (ADR 0061)

# ── Composition ───────────────────────────────────────────────
base: harness/common-base.yaml      # Inherit from another harness (local or URL)

# ── Sandbox ───────────────────────────────────────────────────
image: ghcr.io/fullsend-ai/fullsend-sandbox:latest
policy: policies/base.yaml          # Sandbox policy (filesystem, landlock, process)
model: opus                         # LLM model override
readonly_repo: false                # Mount repo as read-only in sandbox
providers:                           # Network access via provider profiles (ADR 0065)
  - vertex-ai                       # References providers/vertex-ai.yaml
  - github                          # References providers/github.yaml

# ── Skills & plugins ──────────────────────────────────────────
skills:
  - skills/my-skill                  # Local path or URL with #sha256=...
plugins:
  - plugins/gopls-lsp
openshell:                           # OpenShell sandbox profiles
  profiles:
    - https://example.com/profile.yaml#sha256=abc...

# ── Scripts (local paths only) ────────────────────────────────
pre_script: scripts/pre-my-agent.sh
post_script: scripts/post-my-agent.sh
agent_input: inputs/my-input.md     # File passed as initial input to the agent

# ── Validation ────────────────────────────────────────────────
validation_loop:
  script: scripts/validate-output-schema.sh
  max_iterations: 2
  feedback_mode: stderr              # How validation feedback reaches the agent

# ── Host files ────────────────────────────────────────────────
host_files:
  - src: env/my-agent.env            # Runner path (supports ${VAR})
    dest: /sandbox/workspace/.env.d/my-agent.env
    expand: true                     # Resolve ${VAR} in contents
  - src: ${SOME_CREDENTIAL}
    dest: /tmp/.cred.json
    optional: true                   # Skip if missing

# ── Environment ───────────────────────────────────────────────
env:
  runner:                            # Available to pre/post scripts
    MY_VAR: "${MY_VAR}"
  sandbox:                           # Available inside sandbox
    MY_SETTING: "value"
runner_env:                          # Legacy (same as env.runner)
  MY_VAR: "${MY_VAR}"

# ── Timeouts ──────────────────────────────────────────────────
timeout_minutes: 20
sandbox_timeout_seconds: 300         # 30-600

# ── Remote resources ──────────────────────────────────────────
allowed_remote_resources:
  - https://github.com/my-org/agent-library/
allow_runtime_fetch: true
max_runtime_fetches: 10

# ── API servers ───────────────────────────────────────────────
api_servers:                         # Host-side REST proxies exposed to sandbox
  - name: my-api
    script: scripts/api-server.sh    # Local script that runs the server
    port: 8080                       # Port the sandbox connects to
    env:                             # Env vars for the server process
      API_KEY: "${API_KEY}"

# ── Forge-specific overrides ──────────────────────────────────
forge:
  github:
    pre_script: scripts/pre-gh.sh
    post_script: scripts/post-gh.sh
    skills: [skills/github-specific]  # Concatenated with top-level
    env:
      runner:
        GH_TOKEN: "${GH_TOKEN}"
  gitlab:
    pre_script: scripts/pre-gl.sh

# ── Security ──────────────────────────────────────────────────
security:
  fail_mode: closed                  # "closed" (default) or "open"

Field merge rules (for base and forge)

Field typeBehavior
Scalars (model, pre_script, image, etc.)Child wins if non-empty
skills, plugins, providers, api_servers, openshell.profilesConcatenated (base + child)
host_filesConcatenated; child overrides by dest
env, runner_envMerged; child keys win
validation_loop, securityChild replaces entirely
allowed_remote_resources, allow_runtime_fetch, max_runtime_fetchesNOT inherited (child must declare its own)

Referencing resources: local vs. remote

Local paths resolve relative to the harness file's base directory:

yaml
agent: agents/triage.md              # → {base}/agents/triage.md

Remote URLs require a #sha256=... integrity hash:

yaml
agent: https://raw.githubusercontent.com/org/repo/<sha>/agents/lint.md#sha256=abc...

Scripts are local-onlypre_script, post_script, and validation_loop.script must be local paths (they run on the trusted runner). Exception: scripts declared in a base harness fetched via URL are allowed.

Agent definitions

The agent definition is Markdown with YAML frontmatter:

FieldPurpose
nameMust match the filename (sans .md)
descriptionOne-line summary
toolsAllowed Bash commands (e.g., Bash(gh,jq))
modelLLM model
skillsSkill names to mount
disallowedToolsForbidden Bash patterns

Design principles:

  • Agent writes a JSON result file; scripts do all mutations.
  • Be specific — define scoring dimensions, thresholds, output schemas.
  • Include decision points (branch on confidence, clarity scores, etc.).

Skills

A skill is a directory with a SKILL.md file that teaches the agent domain knowledge:

skills/issue-labels/
  SKILL.md            # Required: frontmatter + instructions
  scripts/            # Optional: helper scripts
  references/         # Optional: reference data

Reference in the agent frontmatter by name (skills: [issue-labels]) and in the harness by path (skills: [skills/issue-labels]). Skills can also be URLs with integrity hashes.

Scripts

Pre and post scripts run on the trusted runner outside the sandbox.

  • Pre-scripts prepare the environment — fetch data, reset state, write files for host_files to copy in.
  • Post-scripts act on agent output — apply labels, post comments, create PRs.

Security: treat agent output as untrusted input. Validate JSON structure, validate field values against allowlists, quote all variables, and limit string lengths.

Harness composition with base

Inherit from an existing harness and override only what differs:

yaml
base: https://raw.githubusercontent.com/fullsend-ai/agents/<sha>/harness/triage.yaml#sha256=abc...

model: sonnet
slug: my-org-triage
skills:
  - skills/my-enhancement
timeout_minutes: 15

Base chains support up to 5 levels. Circular references are detected and rejected. Resolution order: base chain → child overrides → forge selection. See field merge rules for how each field type combines.

Note: allowed_remote_resources, allow_runtime_fetch, and max_runtime_fetches are NOT inherited from base harnesses — the child must declare its own. This prevents a base harness from injecting arbitrary URL prefixes or enabling runtime fetching in the child.

Configuring existing agents

You don't need to build from scratch to change how a built-in agent behaves. Use base to inherit the built-in harness and override just the fields you want — then register your configured version so it takes precedence.

Example: add a skill to the code agent

Create a thin harness that inherits from the upstream code agent and adds your skill:

harness/code.yaml:

yaml
base: https://raw.githubusercontent.com/fullsend-ai/fullsend/<sha>/internal/scaffold/fullsend-repo/harness/code.yaml#sha256=abc...

skills:
  - skills/my-custom-linting        # Concatenated with base skills

timeout_minutes: 45                 # Override timeout (scalar → child wins)

skills/my-custom-linting/SKILL.md:

markdown
---
name: my-custom-linting
description: Org-specific linting rules and conventions.
---

# My Custom Linting

[Your skill content...]

Register it:

bash
fullsend agent add harness/code.yaml --name code --fullsend-dir .fullsend

Because config-registered agents take precedence over built-in agents on name collision, your code agent replaces the default — with all of the base agent's scripts, policies, host_files, and plugins still inherited.

Example: swap the model for review

yaml
base: https://raw.githubusercontent.com/fullsend-ai/fullsend/<sha>/internal/scaffold/fullsend-repo/harness/review.yaml#sha256=abc...

model: sonnet

Example: add org-specific environment variables

yaml
base: https://raw.githubusercontent.com/fullsend-ai/fullsend/<sha>/internal/scaffold/fullsend-repo/harness/code.yaml#sha256=abc...

env:
  runner:
    JIRA_TOKEN: "${JIRA_TOKEN}"     # Merged with base env; child keys win
  sandbox:
    JIRA_PROJECT: "MYPROJ"

What you can configure

Any harness field can be overridden. The field merge rules determine how your overrides combine with the base:

  • Change model, timeout, image, scripts — scalars replace the base value.
  • Add skills, plugins, or host_files — your entries are concatenated with the base's.
  • Add or override env vars — maps are merged; your keys win on collision.
  • Replace validation or security config — child replaces the entire block.

Testing locally

Before registering, verify your agent works locally:

bash
fullsend run my-agent --fullsend-dir .fullsend

See Running agents locally for prerequisites (GCP credentials, sandbox image) and troubleshooting.

Registering your agent

Register agents in config.yaml so fullsend discovers them. Both per-repo (.fullsend/config.yaml) and per-org configs support the agents: list.

Authentication for CLI commands uses the gh CLI or GH_TOKEN environment variable. For URL agents, the CLI resolves GitHub blob URLs to raw.githubusercontent.com URLs automatically.

The examples above show customizing built-in agents via base. If you've built an entirely new agent from scratch, register it the same way — just point to a local harness instead of a URL.

CLI

bash
# Add (auto-pins URL with SHA256):
fullsend agent add \
  https://github.com/fullsend-ai/agents/blob/main/harness/triage.yaml \
  --fullsend-dir .fullsend

# Add local:
fullsend agent add harness/my-agent.yaml --name my-agent --fullsend-dir .fullsend

# List / update / remove:
fullsend agent list --fullsend-dir .fullsend
fullsend agent update triage <sha> --fullsend-dir .fullsend
fullsend agent remove triage --fullsend-dir .fullsend

Per-repo config (.fullsend/config.yaml)

yaml
version: "1"
roles: [triage, coder, review]
agents:
  - https://raw.githubusercontent.com/fullsend-ai/agents/<sha>/harness/triage.yaml#sha256=abc...
  - name: my-cool-agent
    source: harness/my-cool-agent.yaml
allowed_remote_resources:
  - https://raw.githubusercontent.com/fullsend-ai/fullsend/
  - https://raw.githubusercontent.com/fullsend-ai/agents/

Per-org config

yaml
version: "1"
dispatch:
  platform: github-actions
defaults:
  roles: [triage, coder, review]
agents:
  - https://raw.githubusercontent.com/fullsend-ai/agents/<sha>/harness/triage.yaml#sha256=abc...
  - name: my-cool-agent
    source: harness/my-cool-agent.yaml
allowed_remote_resources:
  - https://raw.githubusercontent.com/fullsend-ai/fullsend/
  - https://raw.githubusercontent.com/fullsend-ai/agents/
repos:
  my-repo:
    enabled: true

Notes:

  • roles controls which built-in agent roles are enabled. Valid values: fullsend, triage, coder, review, fix, retro, prioritize, e2e. Custom agents registered via agents: do not need to appear in this list.
  • URL entries are automatically pinned with #sha256=... by fullsend agent add.
  • URLs must be covered by allowed_remote_resources in the same config.
  • On name collision, config-registered agents take precedence over built-in agents.
  • Per-repo config is read from the base branch, not from PR branches.

Migrating from customized/

The customized/ directory overlay (ADR 0035) is deprecated in favor of the base: composition and config-driven registration described in this guide (ADR 0064).

If you have existing files in customized/, the fullsend agent migrate-customizations command automates the conversion to config-driven agents.

Preview what would change:

bash
fullsend agent migrate-customizations --fullsend-dir .fullsend --dry-run

Run the migration (creates a PR with the changes):

bash
fullsend agent migrate-customizations --fullsend-dir .fullsend --repo owner/repo

The tool classifies each override and takes the appropriate action:

Override typeDetectionAction
DeadAgent already registered in configDelete customized/ files
CustomNot in upstream scaffoldMove files to regular directories, register local path in config
ModifiedStandard scaffold agent, not yet in configGenerate a base: composition harness with the minimal diff, register in config

For modified agents, the migration produces exactly the kind of thin base: harness shown in Configuring existing agents — only the fields that differ from upstream are included.

Advanced: custom identity

By default, agents authenticate using shared fullsend GitHub Apps via the slug field. If you need your own GitHub App — for custom permissions, compliance, or branding — you can run a standalone mint. Follow the Standalone mint guide to set one up.

Once your standalone mint is running, configure your agent to use it:

  1. Reference your role in the harness:

    yaml
    role: my-role
    slug: my-org-my-role
  2. Set FULLSEND_MINT_URL in your repo to point to your standalone mint.

The standalone mint serves custom roles locally while proxying built-in roles to the hosted mint.

Troubleshooting

SymptomFix
Agent crashes at 0sSandbox can't reach Vertex AI — check ANTHROPIC_VERTEX_PROJECT_ID and CLOUD_ML_REGION
"role field is required"Add role: to harness
Agent can't find input filesPre-script output paths must match host_files entries
Provider blocks requestsCheck that the required provider profile is listed in providers: and exists in the providers/ directory
Schema validation failsCheck agent transcript vs. expected schema
Agent not foundVerify registration: fullsend agent list
allowed_remote_resources errorURL agents require a matching prefix in allowed_remote_resourcesfullsend agent add sets this automatically
fullsend run fails locallyMissing GCP credentials or sandbox image — see Running agents locally
Integrity hash mismatchRemote content changed — run fullsend agent update <name> to re-pin

References