# Run steps, token markers and cost reporting

Agent Mission Control developer docs > Claude skill on Microsoft Foundry

> How the host turns the brain's tool calls into AMC steps, reports tokens and cost exactly once, and makes a failed run end as failed.

**Before you start:** The step and run-status rules in the [integration contract](/developers/integration-contract/).

Foundry reports no usage for hosted agents: the run-level `usage` block arrives all zeros, and nothing shows between `response.created` and the end. So the shell emits progress and spend as **informational `function_call` items** with `call_id` equal to `name`. AMC treats exactly that shape as a step.

## Steps in order

| Step | Emitted by | When | Carries |
| --- | --- | --- | --- |
| `Starting <agent>` | `agent._serve` | When the request is accepted, before any model call | Request fields, agent version, model, ceilings |
| One per **phase** (`Loading the skill`, …) | `steps.StepReporter` | When the phase has been idle for two assistant messages, or at turn end | Tool-call count, elapsed time, up to 8 descriptors, `model` and that phase's tokens |
| One per **produced item** (`Item: <title>`) | `steps.StepReporter` | When a tool result lands and a watched file changed | The entry's own fields, copied |
| **Host** steps (`Publishing the output`, `Dry run: nothing published`, `Output ready`) | `runner._postprocess` | After the turn | Records; `failure_reason` when refused |
| Trailing `<agent>:<model>` markers, then the failure marker | `agent._markers` | Last | Residual tokens, cost, run totals |

Titles are stable across runs and unique within one; a repeat becomes `Title (2)`. Run-specific facts go in `delivered`, never in the title.

## Phases

A phase is a fact about a tool call, never a model's opinion. `PHASE_RULES` maps `(tool, field, regex)` to a phase; the first match wins, so order rules from specific to generic. Calls that match nothing fold into the most recent phase.

`steps.py: phase map for invoice-summarizer`
```python
PHASE_TITLES = {
    "loading": "Loading the skill",
    "inputs":  "Reading invoices",
    "produce": "Writing the summary",
    "verify":  "Verifying totals",
}

PHASE_RULES = [
    # keep the two loading rules from the template
    ("Skill", "skill",     re.compile(r"^(?:.*[:/])?invoice-summarizer$"), "loading", "Skill invoice-summarizer"),
    ("Read",  "file_path", re.compile(r"/\.claude/skills/invoice-summarizer/"), "loading", "read {basename}"),
    ("Read",  "file_path", re.compile(r"/invoices/"),            "inputs",  "read {basename}"),
    ("Bash",  "command",   re.compile(r"verify_totals\.py"),     "verify",  "{basename}"),
    ("Write", "file_path", re.compile(r"\.(json|md|html)$"),     "produce", "write {basename}"),
]
```

- Fields: `url` (host and path, lower-cased), `query`, `command`, `file_path`, or `*` for the whole input as JSON.
- Descriptor templates can use `{value}`, `{compact_url}` and `{basename}`.
- Skill entry is matched on the skill **name**, allowing a `plugin:` or directory prefix. Any installed skill can be loaded by the Skill tool, so "a Skill call happened" isn't evidence this method was entered.
- Give each gate (verify, lint, render) its own phase. Those are the steps a reader wants to see pass.

### Idle close

Closing a phase the moment another phase's call arrives fragments the list into dozens of numbered repeats, because the model spreads one message's calls over several stages. So a phase closes after `PHASE_IDLE_MESSAGES = 2` assistant messages without a call in it. Keep 2.

Test it with a one-message gap. A two-message gap closes the phase either way, so it can't tell correct accounting from an off-by-one. In this example from the skill's own guidance, `lint` maps to a phase titled `Linting the body`:

`Idle-gap test`
```python
run(["lint", "plan", "lint"])           # no "(2)": the phase survived the single gap
run(["lint", "plan", "queue", "lint"])  # "Linting the body (2)": closed after two
```

## Item steps

`ARTIFACT_WATCHES` names a data file the skill writes in its run directory, the list inside it, the key that detects new entries, and the fields to show. The brain composes; the host transcribes.

`steps.py`
```python
ARTIFACT_WATCHES = [
    {"file": "invoices.json", "list": "items", "key": "id", "title": "vendor",
     "title_prefix": "Item: ", "body": ["invoice_no", "date", "amount"]},
]
```

`file` must be a single file name directly in the run directory. Optional `revision_field` and `revision_prefix` report a field's revisions as their own steps, marked `(revised)`. Leave the list empty when the skill produces one opaque output.

## Tokens, exactly once

Each phase step carries the tokens accrued since the previous close, for the model with the most new output, and marks them **attributed**. The trailing marker for each model then carries only `totals − attributed`, never negative: WebFetch's Haiku summaries, the closing narration, anything no step claimed. Summing every step plus the trailing markers gives the run total once.

- **`input_tokens` is cache-inclusive:** uncached + cache read + cache creation. `cached_input_tokens` is the cache-read subset. AMC prices `input − cached` at the input rate and `cached` at the cached rate. Report only the uncached remainder and a warm-cache run is under-priced by orders of magnitude.
- **Every step names its `model`,** even at zero tokens. A step without one is priced at AMC's generic default rate: wrong money, no error.
- **Never put run totals back into `input_tokens`** on the trailing markers while steps carry tokens. AMC would bill the run twice.
- **`cost_usd` follows the same rule.** AMC prefers a reported cost over pricing tokens, so the trailing marker carries the model's exact `cost_usd` only when no step claimed that model's tokens. Otherwise the figure rides as `run_model_cost_usd`, which AMC ignores.
- **`STEP_TOKENS=off`** moves every token onto the trailing markers. Use it for a consumer that reads only the stored background response, where mid-run steps aren't persisted.

### The trailing marker

`One per model`
```json
{
  "type": "function_call",
  "call_id": "invoice-summarizer-agent:<sonnet-deployment>",
  "name": "invoice-summarizer-agent:<sonnet-deployment>",
  "arguments": {
    "delivered": "run complete: 9 steps reported, $1.84, 41 turns, 12 min",
    "input_tokens": 1820, "output_tokens": 412, "total_tokens": 2232,
    "cached_input_tokens": 1500,
    "model": "<sonnet-deployment>",
    "run_model_cost_usd": 1.79, "run_cost_usd": 1.84,
    "run_input_tokens": 912340, "run_output_tokens": 38110,
    "run_cached_input_tokens": 851200, "run_turns": 41,
    "run_duration_s": 734, "steps_reported": 9
  }
}
```

Values are illustrative. One marker per model, each with its own `call_id`: markers that share a `call_id` merge into one item with invalid JSON arguments. Never emit an aggregate marker alongside the per-model ones.

Token figures come from the SDK's `ResultMessage.model_usage`, which is per deployment, covers the whole tree (sub-agents and WebFetch's summarizer included), and is camelCase (`inputTokens`, `cacheReadInputTokens`, `costUSD`). Per-step tokens come from `AssistantMessage.usage`, which repeats on every content-block frame of a message: key it by message id and keep the last.

### activity_log

With `EMIT_STEP_ACTIVITY_LOG=true`, the run's transcript (thinking, tool calls and results, bounded but not redacted) rides on exactly one marker: the model with the most output tokens. AMC renders it as collapsible detail. Never on every marker, never on the failure marker.

## A failed run must end as failed

AMC derives run status from the Responses lifecycle frame: `response.completed` is completed, `response.failed` or an `error` event is failed. A marker with `failure_reason` fails only that step. An agent whose publish is refused, but whose turn ends normally, shows as a green run with one red step.

So `_run_stream` yields the envelope and every marker, then raises:

`agent.py: _run_stream`
```python
failed = bool(error) or bool((result or {}).get("is_error"))   # the FLAGS, never the text
if failed:
    reason = str(error or (result or {}).get("error") or _NO_REASON)
    raise RunFailed(reason[:2000])
```

- The hosting adapter catches it and emits `response.failed` with `error.message` set to the reason. Items already streamed stay on the response, so AMC gets the status, the reason and the spend.
- **Gate on the flags, not the reason text.** An SDK error frame can arrive with an empty reason.
- **Never raise before the markers,** or the spend report is lost. Raise from inside the generator's `try` so the cancel-on-disconnect `finally` still runs.
- **Waiting on a person is not a failure.** A pending question completes the response normally.

### The failure marker

```json
{
  "type": "function_call",
  "call_id": "invoice-summarizer-agent",
  "name": "invoice-summarizer-agent",
  "arguments": {"delivered": "", "failure_reason": "refused to publish: ... verify", "model": "<sonnet-deployment>"}
}
```

Exactly one, named with the plain agent name, never with tokens or `cost_usd`. Emit it for a shell-level refusal (no usage markers exist) and for a runner-level error (usage markers accompany it).

### The non-streaming path can't do this

If `_run_once` raised, the adapter would emit `response.failed` with empty output and the envelope and markers would be lost. So a background request without `stream` always stores a `completed` response, and its envelope's `status` carries the verdict.

| Path | Used by | Run verdict |
| --- | --- | --- |
| Streaming (`stream: true`) | AMC, a person watching | The lifecycle frame, and the envelope |
| Background without `stream` | A raw scheduler polling by id | The envelope's `status` only |

## Completed with issues

A failed step doesn't fail the run, and AMC never works out from failed steps that a run was only partly done. Only an explicit `run.outcome` marker counts. So the shell has three endings:

| Ending | When | Envelope `status` | On the stream | AMC shows |
| --- | --- | --- | --- | --- |
| ok | Every step ran, or every failure was classed `ignore` | `ok` | `response.completed` | **Completed** |
| Completed with issues | A step classed `partial` failed, and nothing fatal did | `partial`, with `output.partial_reasons` | One `run.outcome` marker, then `response.completed` | **Completed with issues**, with the reason |
| failed | Anything fatal: a ceiling, a refused publish, a bad request, a host step that isn't classed | `failed` | The failure marker, then `RunFailed` and `response.failed` | **Failed** |

### STEP_FAILURE_POLICY

`runner.STEP_FAILURE_POLICY` maps a host step's title to what its failure means for the run. You fill it in when you build the agent:

`runner.py`
```python
STEP_FAILURE_POLICY: dict[str, str] = {
    "Notifying the channel": "partial",
    "Refreshing the archive index": "ignore",
}
```

| Value | The run |
| --- | --- |
| `fatal` | Fails. This is the default: a title that isn't listed, or any value other than `partial` or `ignore`, is fatal. |
| `partial` | Completes with issues. The step's title and reason are added to the run's reasons. |
| `ignore` | Stays clean. The step still shows red. |

- A failing host action reports through `runner.host_step_failed(result, reporter, title, delivered, reason)`, which shows the red step and applies the policy.
- `runner.note_partial(result, reason)` records a shortfall the host can compute from the skill's own output, such as a count in its data file.
- Publishing the deliverable is always fatal, so **Publishing the output** is never listed.
- The shell joins the reasons into one marker, capped at 2000 characters. It sends none on a failed run or a paused one. A failure after it still wins.
- The non-streaming path carries the same verdict in the envelope (`status: "partial"`), and the marker is in its output.

### The run.outcome marker

```python
Content.from_function_call(
    call_id="run.outcome", name="run.outcome",
    arguments={"outcome": "partial", "reason": "Notifying the channel: Slack returned 503"},
    informational_only=True,
)
```

- Reserved name `run.outcome`, with `call_id` equal to `name`. AMC reads it from the item's `done` frame and never lists it as a step. It carries no tokens, no `model` and no `delivered`.
- `outcome` must be `"partial"`, the only value AMC acts on. Any other value is ignored and keeps an earlier `partial`.
- `reason` says what wasn't done, in words the user can act on, up to 2000 characters. AMC shows it as the run's reason.
- Send it any time before `response.completed`. The last `partial` wins. A marker after the run has ended is ignored.
- Failed wins. If the run ends `response.failed`, AMC ignores the marker.

## The envelope

The last text item, same shape on every path:

```json
{"status": "ok" | "partial" | "failed" | "awaiting_human",
 "error": null,
 "request": {"mode": "run", "target_date": "2026-10-16", "dry_run": false},
 "output": {"output_path", "output_files", "output_bytes", "gate_evidence", "skills_bypassed",
            "published", "preflight", "steps", "pending_request", "hitl_paused", "partial_reasons"},
 "metrics": {"turns", "cost_usd", "input_tokens", "output_tokens", "duration_ms", "model_usage",
             "step_tokens_attributed", "recovered_errors", "timed_out"}}
```

- `gate_evidence` carries `<gate>_ok` from the host's own run of each gate, plus `<gate>_run_by_brain` as an observation. A failing gate adds `<gate>_output`.
- `skills_bypassed` non-null means the skill was never entered and the output came from improvisation. It is reported, not failed.

## Confidential mode

`EMIT_STEP_ACTIVITY_LOG=false` is the single switch for confidential task data. It withholds the transcript, emits phase steps with counts, timings and tokens but no descriptors, turns item steps into `Item: item 3` with no content, echoes request field names only, masks file and publish-record names in the envelope with `***`, keeps the model's narration in the container, and replaces model-authored error text and request-derived refusal text with a fixed sentence (the detail stays in the container log).

## Rename the run

An agent can rename its own run in AMC with a marker named `run.renamed` whose `call_id` is also `run.renamed`, carrying `{"name": "…"}`. Or add `run_name` to any step's arguments, such as the `Starting …` step. The last non-empty name wins, a blank name is ignored, names are trimmed and capped at 80 characters, and a rename after the run has ended is ignored.

## Related

- [Invocation modes and session lifetime](https://www.agentmissioncontrol.ai/developers/foundry-invocation-and-sessions/)
- [Human-in-the-loop for a Foundry hybrid agent](https://www.agentmissioncontrol.ai/developers/foundry-human-in-the-loop/)
- [The integration contract](https://www.agentmissioncontrol.ai/developers/integration-contract/)

---
Source: https://www.agentmissioncontrol.ai/developers/foundry-steps-and-cost/ · Last updated 2026-10-21 · Full index: https://www.agentmissioncontrol.ai/llms.txt
