Skip to main content

Subagents and Profiles

Subagents and profiles are the two building blocks for multi-agent orchestration in go-code. A subagent is a child agent run you create to handle a bounded piece of work — it has its own run ID, tool set, cost budget, and optionally its own isolated git workspace. A profile is a named TOML file that bundles model choice, step limit, cost ceiling, and a tool allowlist into a reusable preset.

Together they let you build the canonical orchestration pattern: a top-level agent reads a task, selects a profile appropriate for that task, spawns a scoped child run that only has access to what it needs, and waits for the result — without baking any of that configuration into code.


Subagents

Creating a subagent

Send a POST /v1/subagents request. The body must include either prompt or skill — not both. All other fields are optional.

curl -s -X POST http://localhost:8080/v1/subagents \
-H "Content-Type: application/json" \
-d '{
"prompt": "Review internal/parser/parser.go for correctness and missing error handling",
"profile": "reviewer",
"isolation": "worktree",
"cleanup_policy": "destroy_on_completion"
}'

A successful response is HTTP 201 with a subagent object:

{
"id": "subagent_3e2f…",
"run_id": "run_abc…",
"status": "running",
"isolation": "worktree",
"cleanup_policy": "destroy_on_completion",
"workspace_path": "/path/to/repo-subagents/subagent_3e2f",
"branch_name": "subagent/subagent_3e2f",
"created_at": "2026-06-28T12:00:00Z",
"updated_at": "2026-06-28T12:00:00Z"
}

Subagent IDs always use the prefix subagent_ followed by a UUID.

Using a skill as the prompt

Specify skill (and optionally skill_args) instead of a literal prompt. The two fields are mutually exclusive — the server rejects a request that sets both.

{
"skill": "go-deps",
"skill_args": "./internal/...",
"profile": "file-writer",
"isolation": "inline"
}

The harness resolves the named skill to its body (with variable substitution applied) and uses that as the subagent's prompt.

Request fields reference

JSON fieldTypeDescription
promptstringTask prompt. Mutually exclusive with skill.
skillstringNamed skill to resolve as prompt.
skill_argsstringArguments passed to the skill resolver.
profilestringProfile name to apply (see Profiles below).
modelstringOverride the model for this subagent.
provider_namestringOverride the provider.
allow_fallbackboolAllow automatic provider fallback on transient errors.
system_promptstringOverride the system prompt.
max_stepsintMaximum tool-call steps.
max_cost_usdfloat64Per-run cost ceiling in USD.
reasoning_effortstringReasoning hint: "low", "medium", or "high".
allowed_tools[]stringTool allowlist (empty = all tools).
permissionsobjectSandbox policy override (sandbox, approval).
isolationstring"inline" or "worktree" (default: "inline").
cleanup_policystring"preserve", "destroy_on_success", or "destroy_on_completion".
worktree_rootstringOverride the worktree root directory.
base_refstringGit base ref for the worktree.

Isolation modes

The isolation field controls whether the subagent gets its own filesystem.

isolation: "inline" — the subagent runs in the same workspace as its parent using the same runner infrastructure. No separate filesystem is provisioned; workspace_path in the response will be empty. Use this when the child needs to work in the same directory tree as the parent or when you want the lightest possible overhead.

The profile schema accepts isolation_mode values "container" and "vm", but only "inline" and "worktree" have concrete subagent code paths today. Specifying "container" or "vm" in a subagent request will return an error.

Cleanup policies

When a subagent's run reaches a terminal state (completed, failed, or cancelled), a background monitor applies the cleanup policy. This only has visible effect for worktree-isolated subagents where a workspace was provisioned.

PolicyJSON valueWhen workspace is removed
CleanupPreserve"preserve"Never — requires explicit DELETE /v1/subagents/{id}
CleanupDestroyOnSuccess"destroy_on_success"Only when status is completed
CleanupDestroyOnCompletion"destroy_on_completion"On any terminal status

The default cleanup policy differs by layer. When you call POST /v1/subagents directly, the default is "preserve" — the workspace is kept until you explicitly delete it. When a running agent spawns a subagent through the in-run tool layer (start_subagent), the default is "destroy_on_completion".

Polling, waiting, and cancelling

Once you have a subagent ID, you have four management operations:

  1. Poll for statusGET /v1/subagents/{id} returns the current subagent object. Useful when you want non-blocking progress checks.

    curl -s http://localhost:8080/v1/subagents/subagent_3e2f...
  2. Block until terminalPOST /v1/subagents/{id}/wait holds the HTTP connection open, polling internally at 200 ms intervals, and returns the subagent object only when the run reaches a terminal status. The response output field contains the agent's final output.

    curl -s -X POST http://localhost:8080/v1/subagents/subagent_3e2f.../wait
  3. CancelPOST /v1/subagents/{id}/cancel requests cooperative cancellation. The cancel endpoint returns an acknowledgement ({"id":…,"status":"cancelling"}); the subagent's actual status then transitions to cancelled (there is no persisted cancelling status).

    curl -s -X POST http://localhost:8080/v1/subagents/subagent_3e2f.../cancel
  4. DeleteDELETE /v1/subagents/{id} removes the subagent record and, for worktree isolation, destroys the workspace. Returns HTTP 409 Conflict if the subagent is still active (queued, running, or waiting_for_user). Cancel it first.

    curl -s -X DELETE http://localhost:8080/v1/subagents/subagent_3e2f...

Subagents route summary

MethodPathScopeNotes
GET/v1/subagentsruns:readList all subagents
POST/v1/subagentsruns:writeCreate — returns HTTP 201
GET/v1/subagents/{id}runs:readGet by ID
DELETE/v1/subagents/{id}runs:writeDelete — HTTP 409 if active
POST/v1/subagents/{id}/waitruns:readBlock until terminal
POST/v1/subagents/{id}/cancelruns:writeCancel

Returns 501 Not Implemented if the server was not started with a SubagentManager configured.


Profiles

A profile is a named TOML file that bundles runner parameters, a tool allowlist, and optional permission and workspace settings into a single preset. You reference it by name in a subagent or run_agent request and the harness applies all of its constraints automatically — no need to repeat the same max_steps, model, and allowed_tools in every request. (On the top-level POST /v1/runs path the profile field currently governs isolation mode, workspace provisioning, and MCP server selection only — the tool allowlist and step/cost limits from the profile are not enforced there.)

Profiles are loaded lazily on every LoadProfile call, so changes to TOML files on disk take effect without restarting the server.

Anatomy of a profile

extends = "researcher" # optional: inherit from another profile

[meta]
name = "deep-researcher"
description = "Like researcher but also fetches page content"
version = 1
created_by = "user" # "built-in" | "agent" | "user" | "api"

[runner]
model = "gpt-4.1-mini"
max_steps = 15
max_cost_usd = 0.40
system_prompt = "You are a thorough research assistant."
reasoning_effort = "medium"

[tools]
allow = ["read", "grep", "glob", "ls", "web_search", "web_fetch"]

[permissions]
allow_bash = false
allow_file_write = false
allow_net_access = true

[runner] zero-value semantics: a zero value (0 or "") means "no profile-level limit" — the server's own defaults apply. max_cost_usd = 0.0 means unlimited for this profile.

[tools] semantics: an empty allow list (allow = []) means all tools are available, not zero tools. A non-empty list is a strict allowlist.

[permissions] semantics: all permission fields default to false (no override). There is no way to use a profile to revoke a permission that was granted at a higher precedence tier.

Profile inheritance

A profile can extend another with extends = "<base-name>". The child's non-zero fields override the base; zero-value fields fall back to the base. For tool allowlists, if the child defines any list (including an empty one), the child's list wins entirely. Cycle detection is built in and returns an error if a cycle is found.

extends = "researcher"

[meta]
name = "deep-researcher"
description = "Like researcher but also allows web_fetch"
version = 1

[tools]
allow = ["read", "grep", "glob", "ls", "web_search", "web_fetch"]

Resolution tiers

When go-code resolves a profile name, it checks three tiers in priority order. The first match wins — tiers are not merged.

1. Project

.harness/profiles/<name>.toml — relative to the harnessd working directory. Highest priority.

2. User-global

~/.harness/profiles/<name>.toml — applies across all projects for the current user.

3. Built-in

Embedded in the harnessd binary. Read-only — cannot be modified or deleted via the API.

Built-in profiles

Six profiles are embedded in every harnessd binary. All use model = "gpt-4.1-mini" and have review_eligible = false (they never participate in the efficiency review system).

Namemax_stepsmax_cost_usdTools allowed
full30$2.00all (empty allowlist)
researcher10$0.25read, grep, glob, ls, web_search, web_fetch
reviewer10$0.25read, grep, glob, ls, git_diff
file-writer15$0.50read, write, edit, apply_patch, bash
bash-runner10$0.25bash
github20$0.50bash, read

Built-ins cannot be modified or deleted. POST /v1/profiles/{name} returns HTTP 409 if the name matches a built-in. PUT and DELETE return HTTP 403.

Selecting a profile in a request

Set the "profile" field in a subagent or run_agent request body:

# Subagent with the built-in reviewer profile — tool allowlist and step/cost limits enforced
curl -s -X POST http://localhost:8080/v1/subagents \
-H "Content-Type: application/json" \
-d '{"prompt": "Review changes in src/parser.go", "profile": "reviewer"}'

# Subagent with a custom project profile
curl -s -X POST http://localhost:8080/v1/subagents \
-H "Content-Type: application/json" \
-d '{"prompt": "Scan for SQL injection vulnerabilities", "profile": "deep-researcher"}'

Per-request fields (model, max_steps, max_cost_usd) override the values the profile would otherwise supply for that run.

Separately, the server resolves its baseline configuration through a layered cascade. From highest precedence to lowest, the layers are: HARNESS_* environment variables → the --profile named profile → project .harness/config.toml → user ~/.harness/config.toml → compiled-in defaults. The --profile flag at harnessd startup loads a profile as the server-wide default for all runs that do not explicitly specify one.


Profile Management API

The profile API lets you create, read, update, and delete user-tier profiles without touching the filesystem directly. The mutation endpoints (POST, PUT, DELETE) require the server to have ProfilesDir configured — they return 501 Not Implemented otherwise.

Routes

MethodPathScopeNotes
GET/v1/profilesruns:readList profiles from all tiers; returns {"profiles":[…],"count":N}
GET/v1/profiles/{name}runs:readGet full metadata including source_tier and allowed_tools
POST/v1/profiles/{name}runs:writeCreate — HTTP 201; HTTP 409 if built-in
PUT/v1/profiles/{name}runs:writeUpdate — HTTP 403 for built-ins, HTTP 404 if not found
DELETE/v1/profiles/{name}runs:writeDelete — HTTP 403 for built-ins, HTTP 404 if not found

Request body for POST and PUT

{
"description": "Strict read-only code review",
"model": "gpt-4.1-mini",
"max_steps": 10,
"max_cost_usd": 0.25,
"system_prompt": "You review code for correctness and style. Never write or modify files.",
"allowed_tools": ["read", "grep", "glob", "ls", "git_diff"]
}

Fields omitted from a PUT body retain their existing values. description is required on POST (the server validates it is non-empty).

Profiles created via the API have created_by = "api" and review_eligible = true by default, meaning they participate in the efficiency review system.

Example: create and verify a custom profile

# Create a new user-tier profile
curl -s -X POST http://localhost:8080/v1/profiles/security-scanner \
-H "Content-Type: application/json" \
-d '{
"description": "Read-only security review — no writes or network access",
"model": "gpt-4.1-mini",
"max_steps": 12,
"max_cost_usd": 0.30,
"allowed_tools": ["read", "grep", "glob", "ls"]
}'

# Verify it was stored
curl -s http://localhost:8080/v1/profiles/security-scanner

# Update — bump max_steps without changing anything else
curl -s -X PUT http://localhost:8080/v1/profiles/security-scanner \
-H "Content-Type: application/json" \
-d '{"description": "Read-only security review — no writes or network access", "max_steps": 20}'

# Delete when no longer needed
curl -s -X DELETE http://localhost:8080/v1/profiles/security-scanner

Profile Recommender

The harness includes a deterministic, keyword-based recommender that suggests a profile name given a task description string. It requires no LLM — it is a fast heuristic you can call from orchestration logic.

The recommender is exposed as the recommend_profile deferred tool (activated via find_tool inside a run), or you can call profiles.RecommendProfile(task) directly from Go code.

Rules are evaluated in order; the first match wins:

Task contains…Suggested profileConfidence
review, audit, check, analyzereviewerhigh
research, search, find, investigateresearcherhigh
bash, shell, script, run commandbash-runnerhigh
write file, create file, edit filefile-writerhigh
github, pull request, pr, issuegithubhigh
(no match)fulllow

The match is a case-insensitive substring check. A Recommendation result carries ProfileName, Reason, and Confidence ("high" on a keyword match, "low" on fallback).


Profile Efficiency System

After each run that uses a profile, the harness computes an efficiency score and optionally emits suggestions for tightening the profile.

Score formula:

efficiency = 1.0 / (1.0 + steps * 0.1 + costUSD * 10.0)

Scores range from 0.0 to 1.0 — higher is better. When a run's score falls below the threshold of 0.6 (EfficiencyThreshold), a profile.efficiency_suggestion event is emitted on the run's SSE event stream. The payload includes profile_name, run_id, efficiency_score, steps, and cost_usd.

Efficiency suggestions are never auto-applied. They are informational events only — your code or an operator must decide whether to act on them. In addition, GenerateSuggestions requires at least 3 completed runs for the same profile before it produces actionable output; with fewer runs it returns "Not enough history to generate suggestions (need ≥ 3 runs)".


Putting it together: the orchestration pattern

The most common multi-agent pattern in go-code:

  1. A top-level run receives a broad task (e.g. "review and then fix the security issues in internal/auth").

  2. The agent calls find_tool to activate recommend_profile and run_agent.

  3. It calls recommend_profile with the task description to get a profile name and confidence.

  4. It calls run_agent with the task and the suggested profile. The child executes with the profile's tool allowlist, step limit, and cost ceiling enforced. It cannot exceed those bounds even if the parent could.

  5. The child's output is returned to the parent as the run_agent tool result. The parent continues with the next step of the broader task.

Because profiles are TOML files resolved lazily, you can ship task-appropriate presets alongside your project in .harness/profiles/ and reference them by name from any orchestration code without changing Go source.


Next steps

  • Concepts overview — how subagents, profiles, and skills compose as go-code's three orchestration primitives: Skills, Profiles, and Subagents
  • HTTP API reference — full route tables, request/response shapes, and authentication: see the server API reference section
  • Events — the full event catalog and SSE wire format: Events