- name
- create-background-agent
- description
- Write a dltHub background agent: an AGENT.md that runs unattended on the platform, on a schedule, after a job fails, or when someone starts it from the web UI. Use when the user wants work to happen on its own after an event ('whenever a job fails, diagnose it for me', 'every Monday summarise what broke'), when they ask how to write, declare or deploy an AGENT.md, how to pick its access, tools, inputs or output, how to set run.agent, or how to ship an agent in a toolkit. Do NOT use for a host subagent in .claude/agents/, for scheduling a pipeline or a plain job (deploy-workspace), for diagnosing one failure right now (debug-deployment), or for setting AGENT__MODEL and the other AGENT__ variables, which belong to any agent job including the shipped job-inspector.
# Create a background agent
A **background agent** is a toolkit item alongside skills, commands and rules. It runs
unattended: on a schedule, after a job fails, or when someone starts it from the web UI or the
command line. An agent is an `AGENT.md`, written much like a `SKILL.md`. The working example is
`workbench/dlthub-platform/dlthub/agents/job-inspector/AGENT.md`, installed as
`.claude/dlthub/agents/dlthub-platform/job-inspector/AGENT.md`.
**Essential reading**: AI harness `https://dlthub.com/docs/hub/ai-harness/introduction.md`,
triggers and scheduling `https://dlthub.com/docs/hub/pipeline-operations/triggers.md`, profiles
`https://dlthub.com/docs/hub/pipeline-operations/profiles.md`
Reference files beside this skill:
- [agent-md-reference.md](agent-md-reference.md) owns every frontmatter field.
- [summary-format.md](summary-format.md) owns the shape of `summary` and the link helper.
- [deployment.md](deployment.md) owns `run.agent`, the profile pin, triggers, the hook that
fetches evidence before the loop, and the `AGENT__` variables.
- [check_agent.py](check_agent.py) checks one `AGENT.md` against the rules a provider imposes,
and sizes its system prompt against the turn budget.
## Terms
- **agent definition**: what the `AGENT.md` declares. The system prompt, the inputs and output,
the tools, skills and rules the agent uses, and the access it needs. A toolkit ships
definitions. This skill is about writing one.
- **agent job**: a definition plus the settings that say how it operates in one workspace: model,
token and turn limits, trigger, instructions, loop. A workspace declares it with
`run.agent("<toolkit>:<name>", ...)`.
- **agent run**: an execution of the agent job. It takes inputs, for example a job run id, and
returns the agent output and a trace. Instructions, limits and model can be overridden for a
single run through job configuration.
## 1. Decide whether this is an agent at all
Three things get confused with a background agent. Route back when one of them is what the user
wants:
- **A scheduled pipeline or a plain job.** The work is deterministic and a person wrote the code.
That is `deploy-workspace` in **dlthub-platform**.
- **A single failure the user wants explained now.** That is `debug-deployment`.
- **A host subagent.** A Claude Code agent in `.claude/agents/`, or the equivalent on another
host, runs inside a conversation. A dltHub agent runs on the platform with no conversation
around it, which is why it installs under `.claude/dlthub/agents/`.
A background agent is right when the decision needs a model, the work runs with nobody watching,
and the output stands on its own.
## 2. Scaffold the folder
```
agents/<name>/AGENT.md # in a workspace
workbench/<toolkit>/dlthub/agents/<name>/AGENT.md # in a toolkit
```
A folder, like a skill, so a definition can grow supporting files. In a workspace put it at the
workspace root and point the deployment at the folder path. In a toolkit it goes under
`workbench/<toolkit>/dlthub/agents/<name>/`, and **dlt 1.31.0** copies it on
`dlthub ai toolkit install <toolkit>` to `.claude/dlthub/agents/<toolkit>/<name>/`
(`.cursor/dlthub/agents/`, `.agents/dlthub/agents/` on the other hosts). The toolkit segment is
part of the installed path, so two toolkits may ship an agent of the same name. dlt 1.30.1a1
wrote the folder flat at `.claude/dlthub/agents/<name>/`, and an older dlt reads
`<toolkit>/agents` and skips the folder without a word: the install reports its skills and rules,
`.claude/dlthub/` is never created, and `<toolkit>:<name>` does not resolve. Check
`dlthub --version`, and read the install output for the `+ agent <name>` line. The `dlthub/`
segment is on both sides: a toolkit's plain `agents/` folder is the host's own subagents folder,
which dltHub neither installs from nor validates, and the hosts scan their own folders for native
subagents. A workspace refers to an installed agent as
`<toolkit>:<name>`, and the workspace's toolkit index (`.dlt/.toolkits`) travels with every
deployment, so the reference resolves on the runner as it does locally.
Shipping an agent in a toolkit means working in a clone of the AI harness repo
(`https://github.com/dlt-hub/dlthub-ai-harness`), since the folder lives beside the toolkit's
skills and `make validate-toolkits` checks it there. The definition then travels to everyone who
installs the toolkit, and upgrading it costs a change in the repo and a reinstall in every
workspace. Put an agent only one workspace runs in that workspace's `agents/` folder.
The file is YAML frontmatter and a markdown body. The frontmatter declares what the agent has,
and the body is its system prompt. Only the body is required: a file with no frontmatter is a
valid definition, named after its folder, with the standard output.
```yaml
---
name: <folder name>
description: what it does and when to run it
tools: []
access: {}
inputs:
type: object
properties: {}
required: {}
output:
type: object
properties: {}
required: [status, summary]
defaults:
limits: {max_turns: 30, max_tokens: 1000000}
---
```
## 3. Declare `tools`, `access`, `skills` and `rules`
Each field takes the narrowest set the task needs. [agent-md-reference.md](agent-md-reference.md)
has the field table, the access axes and the verbs.
- `tools`: the feature groups of the dlthub MCP server the agent gets.
- `access`: what it may touch, per axis: `local`, `data`, `context`.
- `skills` and `rules`: `<toolkit>:<name>` refs to components it uses.
**Grant an axis only where the agent has to choose what it reads.** `job-inspector` grants
`local: read` and `context: read` because it investigates an open question and cannot know which
file or which record answers it. An agent that answers a fixed list of questions grants nothing
and declares no `tools`: the code around its loop fetches every artifact those questions read
before the loop starts and hands the model bounded windows.
Warehouse rows take the Python route. Every tool the `data` axis buys takes a `pipeline_name`
and restores that pipeline from the run directory, and an agent job on the platform starts with
an empty run directory, so the first call fails with `No local state found` and the run aborts
once `loop_run_args.retries` is spent. An agent that needs rows reads them before the loop with
`dlt.dataset()` and renders a bounded window into the system prompt. "Row evidence through a
hook" in [deployment.md](deployment.md) has the code.
`job-inspector` grants no `data`. It works from run records, logs, job definitions, telemetry and
source, and a `data` grant would put workspace data in front of a model-driven process.
### Hold the grant narrow
dltHub wires only what `access` and `tools` name, so a verb you leave out is a tool the model is
never offered.
- **Stay on the `pydantic-ai` loop.** The shipped agents are written and graded against it, and
the wiring described here is its wiring. Another loop brings its own toolset, which none of
this has been checked against.
- **Leave `local: execute` out.** The shell and `RunPython` run in the job's own process under
the job's credentials, and the rules that keep the file tools out of `*secrets.toml` and
`.env` do not reach them. Workspace variables arrive as process environment, so an agent
without `execute` has no way to read one. Grant it where the task has no other route, and
write into the body what it may run.
- **Take the smallest destination access.** Leave `data` out where run records, logs, job
definitions, telemetry and source answer the question. Where rows are the task, read them in a
hook around the loop: the tools a `data` grant buys need pipeline state the runner has not got,
and the run aborts on the first call.
- **List the fewest feature groups.** A group brings every tool it holds that the access covers.
A dependency added to the workspace can contribute tools to a group the agent already lists,
so keep the workspace to what it needs.
- **A deployment argument replaces the grant.** `access=` on a decorated function driving a
definition through `agent=` replaces the whole block rather than merging it, so an argument
naming one axis drops the others. Leave those arguments off and let the `AGENT.md` hold the
grant, or repeat every axis the agent needs. A reference with no function behind it keeps the
definition's lists and the same arguments are dropped. Step 6 and
[deployment.md](deployment.md) have the rest of what the Python overrides.
These rules apply equally to an agent adapted from a shipped definition. Copy an `AGENT.md` and
widen `access` or `tools`, and the grant is no longer the one that was graded.
## 4. Declare `inputs` and `output`
`inputs` is a JSON Schema and every property is a job configuration key. An input that names a
workspace entity carries `entity_type`. The body must say what to do with partial input: which
combinations are workable, and when to abort.
`output` is a JSON Schema of what the agent returns. `status` and `summary` are the contract every
agent shares; declare them with the standard values so the file shows the whole contract. A
domain outcome gets its own name: a data-quality agent returns `verdict`.
Both fields are in [agent-md-reference.md](agent-md-reference.md), with the entity rules, the
schema constraints the providers impose, and the size bound.
## 5. Write the body
The body is the system prompt: who the agent is, what it produces, what "done" means, and the
task itself with its inputs named. It is a template: `{{ name }}` and `{{ a.b }}` are substituted
before the first turn, and that is the whole grammar. Write it as you would a skill, for a reader
that has the tools and none of the context.
What the model gets besides the body: the rules inlined, the skills listed or inlined, a sentence
naming the workspace folder and the temp folder for scratch files, the output schema with its
descriptions, and the tools `access` and `tools` bought. What it gets as the user turn is the
agent job's `instructions`, or a bare "Go ahead". Do not restate any of that.
These seven points use `job-inspector` as the example:
1. **State the role in two sentences, including the unattended setting.** "You run unattended,
seconds after a job failed. An engineer reads your output only when the failure matters, so it
must stand on its own."
2. **Define `succeeded`, `failed` and `aborted` for this agent.** The schema deliberately does
not. Write the bar as a positive claim and name the failure mode you fear; with constrained
decoding the cheap escape from `failed` is a guess dressed as success. "The distinction that
matters is cause found versus cause not found, not whether the problem got solved."
3. **Say what to do with each input, and with its absence.** Inputs are usually optional. Name
the fallbacks in order, and the point at which nothing is left to work on and the answer is
`aborted`.
4. **Give the first steps concretely, and name the page size of every list tool.** Which tool or
command to run first, what to read, what the tell-tale signs are. A skill reference is good
here; a skill the agent has is loaded on demand. A list tool left to the model takes the
largest page it offers rather than its default of 20, and the first turn's results are resent
on every turn after it: a run
that opened with `dlthub_list_jobs {"limit": 100}` and `dlthub_list_runs {"limit": 100}` died
at 448,613 tokens against a 400,000 ceiling in seven turns with no wandering, and the same task
at `limit: 40` finished at 326,688. Write the number into the body for each list call, and say
to page again only when the first page does not answer the question. "The catalogue" in
[agent-md-reference.md](agent-md-reference.md) names every tool each `tools` group holds, its
page parameter and its cap. Read it before writing this part: the parameter is `max_lines` on
`dlthub_get_run_logs` and `limit` elsewhere, `dlthub_list_runs` already carries the row counts
that `dlthub_get_run` would cost a call a run to fetch, and that listing returns the runs of
archived jobs unless it is given a `job`.
5. **Write each constraint as a rule.** "Never edit code, never deploy, never re-run a job" beats
"be careful".
6. **Define every enum the output declares.** A table of value and when it applies. Say what
`unknown` or `low` means and that reporting it is a legitimate outcome.
7. **Reserve turns for the answer.** Writing the summary costs a turn of its own, so an agent
that reads until the limit returns nothing, and a run with no summary is worth nothing however
thoroughly it investigated. A run that obeyed every rule above, batched its opening
reads and bounded every payload, still spent its tenth turn on one more confirmation and ended
with no output. Give a counted cut-off at about half of `max_turns`: "Stop reading after your
fifteenth turn. From the sixteenth on, write the answer from what you have." Say in the same
place that a question still open at the cut-off goes in the open points. "Short of turns, write
what you have" is too soft to act on. One model turn is one request, so the number sits on the
same scale as `max_turns`, and from dlt 1.31.0 on the same scale as the transcript's `turn N`
lines. What `max_turns` counts is in [agent-md-reference.md](agent-md-reference.md).
State the summary sections in the body and hold to them. Take the default set for this kind of
agent from [summary-format.md](summary-format.md), which also carries the shape rules the
renderer imposes.
Keep the body under about two hundred lines. The rules and skills it references hold the platform
knowledge, so the body holds what this agent has to decide.
## 6. Deploy it
**STOP and show the deployment plan before writing it**: the trigger, the profile, the model
variable, and which jobs the agent watches. [deployment.md](deployment.md) has the declared form,
the decorated form for code around the loop, the trigger strings and the `AGENT__` variables.
The minimum: `run.agent("<toolkit>:<name>", trigger=..., require={"profile": "access"})`, and
`AGENT__MODEL` set once in the workspace.
The Python overrides the file, and [deployment.md](deployment.md) has the table of what each form
overrides. Write the `AGENT.md` as the whole definition and put in the deployment only what this
workspace changes, so the file stays the thing that was reviewed and graded.
Three stops apply to every agent job. [deployment.md](deployment.md) has the mechanics behind
each:
- **An agent job must never run on the `prod` profile.** The runtime defaults it to `access`. Pin
`require={"profile": "access"}` so the declaration says so, and never override it with `prod`.
This is rule 4 of the always-loaded `dlthub-platform` profiles rule.
- **`job.fail:*` starts a loop where another agent job can fail.** Name the jobs to watch, or
tag them.
- **`agent.verbosity` stays at 1**, the default. At 0 the job log keeps tool names only, and every
check that reads tool arguments or the agent's own statements goes blind.
## 7. Run it by hand and read the result
Check the active profile first: on `dlthub local run` the `require` declaration is a warning
rather than a switch, so the run uses the active profile and reports the mismatch. Then run the
job with its inputs as configuration keys and read the job result it prints, both shown in
[deployment.md](deployment.md).
Run the offline checks first, in "Check it offline, before it costs a run" in
[deployment.md](deployment.md). They exercise the hooks, the placeholders, the inlined components
and the output schema with no provider key and no tokens spent. A deployed run is the expensive
way to find a typo, and a local run needs the secret workspace variables, which do not sync down.
Read the trace against what you declared. A tool in it that the task does not need is an `access`
axis to drop. Divide the tokens by `trace["turn_count"]`, the model requests the run made and the
count the run header prints: that is what one request of this agent costs, and `max_turns` times
it is the run's bill. From dlt 1.31.0 the transcript's `turn N` lines are the same count; up to
1.30.1a1 they ran two to a request, so a budget sized off an older transcript is out by a factor
of two. A request count at the limit is a body that did not say where to stop, and the fix is the
body, not a larger `max_tokens`.
## 8. Check it
Run the checker beside this skill over the folder first. It reports the schema size, the
optional-property count against the cap, untyped properties, bare objects, the `status` and
`summary` contract, a body placeholder no input declares, and a `tools` group whose `access` axis
the grant leaves out, per the catalogue in [agent-md-reference.md](agent-md-reference.md). Each of
those either fails a run on the first model call or leaves the agent without the tools the file
says it has. It also measures the system prompt, body plus every rule and skill the file lists, and
prints what `max_turns` of it costs against `max_tokens`, which is the number step 7 asks you to
size. A reference it cannot resolve is a component the agent will not get.
```bash
uv run python .claude/skills/create-background-agent/check_agent.py agents/<name>
```
`check(target)` returns `(errors, warnings, report)`, so the same checks run as a pytest case
beside the ones that run the agent. Keep the case in the suite so a frontmatter change is checked
on every commit.
```python
import sys
sys.path.append(".claude/skills/create-background-agent")
from check_agent import check
def test_the_agent_file_holds_up() -> None:
errors, warnings, report = check("agents/<name>")
assert errors == []
# the system prompt is resent every turn, so hold it where the history still fits
assert report["max_turns"] * report["prompt_tokens"] < report["max_tokens"] // 2
```
Both paths are read from the working directory, so run pytest from the workspace root.
The run itself is tested the way dltHub documents at
`https://dlthub.com/docs/hub/agents#test-agent-jobs`: await the job with its inputs and a
`run_context`, assert on the output and on `last_job_result`. That test renders the prompt, wires
the tools and validates the output against the schema, which reading the file cannot reach. Keep
both. [deployment.md](deployment.md) has the fixtures.
Then read the list:
- The folder name is the agent's name; `description` says when to run it.
- `tools` lists only the feature groups the task needs; `access` only the verbs it needs.
- `access` holds no `local: execute` and no `data` the task does not read, and the job leaves the
loop at `pydantic-ai`.
- Every input has a `description`, entity inputs have `entity_type`, and the body names every
input and says what to do when it is empty.
- `output` keeps `status` and `summary` as declared, and describes every field of its own; an
entity the agent may resolve itself is an output property too.
- The body defines succeeded, failed and aborted for this agent, gives the first steps, and
defines every enum.
- `defaults` holds sensible limits and no `model` and no `trigger`; the `AGENT.md` says what model
to pin and the deployment sets the trigger. Nothing in `defaults` is a requirement.
- The deployment changes only what this workspace needs, since every argument it passes overrides
the file.
- For an agent shipped in a toolkit: `make validate-toolkits` passes. It checks the frontmatter,
the placeholders in the body, the `access` vocabulary, the `status` and `summary` contract, the
component refs and the `defaults` keys. dlthub validates again when the deployment manifest is
generated.
An agent that runs unattended is read by a person only when its output matters, so its adherence
to its own instructions goes unchecked. Read a sample of its runs yourself after it goes live,
against the body you wrote and the trace of each run.
View on GitHub