---
title: "Playbook advisor"
description: "Optionally let a model read signals and norms and propose one of the app's declared playbooks with a reason a person approves; off by default."
section: "interfaces"
status: "Building for 0.6 · ships as a experimental API"
spec: "advisor"
canonical: "https://mainsdk.dev/docs/interfaces/advisor"
updated: "2026-10-28"
---

# Playbook advisor

> Optionally let a model read signals and norms and propose one of the app's declared playbooks with a reason a person approves; off by default.

Status: Building for 0.6 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/advisor · Updated: 2026-10-28

`advisor` lets a model read your signals and propose one of the playbooks you declared, with a reason. A person approves. It is off unless declared, and it never runs on the request path.

> **Note:** Experimental (milestone 0.6). Run-verified offline against the scripted `local` driver and the test simulator; a real model needs an `ai` account.

## At a glance

- Status: Building for 0.6 · ships as a experimental API
- Phase: 2 (Cloud and Teams, 1.0)
- Milestone: 0.6 Cloud
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/advisor.md
- Category: AI and agents

Calls:

- `advisor.propose({ signals? })`
- `advisor.get(id)`
- `advisor.list({ cursor?, limit? })`

In main.yaml:

```yaml
advisor:
  on: [attack.suspected, anomaly.detected]
  may: [attack.contain, pressure.slim]
  run: approve
  model: anthropic/claude-sonnet-4-5   # optional; the first of ai.models by default
  feature: advisor                     # the ai feature its calls are made under
```

Guarantees, whatever the driver:

- Chooses only from declared playbooks, or none
- Never approves, and approval is the default
- Sees signals and norms only, redacted


## Drivers

Pick one in main.yaml with `advisor.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (calls the ai interface) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-ADVISOR-0001` | check | `advisor` is invalid: `may` empty or naming an undeclared rule, `run: auto` with a rule in `may` that has an `app` or `people` step, an unknown wake signal, or no `agents.advisor` |
| `MAIN-OPS-0001` | check | An `ops` value is invalid: `write: auto`, a port outside 1024 to 65535, or `http: on` in production without `ops.agent` declared under `agents` |
| `MAIN-ADVISOR-0002` | runtime |  |
| `MAIN-ADVISOR-0003` | runtime |  |
| `MAIN-ADVISOR-0004` | runtime |  |
| `MAIN-OPS-0002` | runtime |  |
| `MAIN-OPS-0003` | runtime |  |
| `MAIN-OPS-0004` | runtime |  |
| `MAIN-OPS-0006` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Proposals limited to declared playbooks | 2 | 0.6 | `advisor#propose` |
| Offline local driver for tests | 2 | 0.6 | `advisor#offline` |
| The operate surface over MCP | 1 | 0.4 | `ops-mcp#tools-read` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
advisor:
  on: [attack.suspected, anomaly.detected]
  may: [attack.contain, pressure.slim]
  run: approve
agents:
  advisor: { budget: { spend: $2/day, calls: 50/day } }
```

```ts title="ops/ask.ts"
import { advisor } from "mainsdk/experimental/advisor";

const p = await advisor.propose();
console.log(p.choice, p.reason, p.evidence);
```

## Configuration

| Key | Default | Meaning |
| --- | --- | --- |
| `on` | none | Signals that wake it, at medium severity or above |
| `may` | required | Playbooks it may propose; at least one |
| `run` | `approve` | `approve` holds every proposal for a person. `auto` is allowed only when every step of the chosen rule is a notice or instance step |
| `window` | `15m` | How far back it reads signals |
| `max-per-hour` | `4` | The most times it runs in an hour |
| `feature` | `advisor` | The `ai` feature that sets models, redaction and limits |
| `model` | first of `ai.models` | The model it asks |

## What you get

Server only.

| Call | What it does |
| --- | --- |
| `advisor.propose({ signals? })` | Ask for a proposal now. Returns a `Proposal` with `choice` (one of `may`, or `none`), `reason` (up to 600 characters), `evidence` (signal ids), `confidence` (0 to 1, shown but never used to skip approval) and `state` |
| `advisor.get(id)` | Read one proposal |
| `advisor.list({ cursor?, limit? })` | List proposals, newest first |

A proposal with a choice starts a playbook run with source `advisor`, held for approval unless `run: auto` applies.

Always:

- Chooses only from declared playbooks, or none.
- Never approves, and approval is the default.
- Sees signals and norms only, redacted, under 16 KB. It never sees logs, request content, app data, config values, secrets or user identifiers.
- Calls the model through `ai` as agent `advisor`, so the model allowlist, region, budget and audit apply.

## Examples

With `ai.driver: local`, replies come from the scripted fixture you give the test app:

```ts title="test/advisor.test.ts"
import { createTestApp } from "@mainsdk/testing";

const app = await createTestApp({
  ai: { replies: [JSON.stringify({ choice: "attack.contain", reason: "Many failed sign-ins", evidence: ["s1"], confidence: 0.8 })] },
});
const p = await app.advisor.propose();   // evidence aliases s1..sN map back to real signal ids
```

## Errors and checks

| Code | Meaning |
| --- | --- |
| `MAIN-ADVISOR-0001` | Check: `advisor` is invalid, for example `may` is empty or names an undeclared rule |
| `MAIN-ADVISOR-0002` | The model's output didn't fit; it was refused and logged |
| `MAIN-ADVISOR-0003` | The advisor stopped (budget, rate or model failure); playbooks carry on |
| `MAIN-ADVISOR-0004` | The input matched a secret or personal-data detector; the field was dropped |

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

| Driver | Provider | |
| --- | --- | --- |
| `builtin` | Built in, through the `ai` interface | default |

The advisor asks whichever `ai` driver the app uses. Proposals are kept in memory (the last 200); the playbook run they start is durable.

## Testing

With `ai.driver: local` the advisor runs offline on the scripted reply and the fake clock, so a simulated attack gives the same proposals every time and makes no outbound connection. See [Test your app](https://mainsdk.dev/docs/guides/testing).

## Security and privacy

- The model sees only redacted signals and norms, never logs, app data, config values, secrets or user identifiers.
- It cannot approve, revert, change a rule or name a step.
- Calls run under the `advisor` agent's budget and the `ai` allowlist and region.

## Next

- [AI](https://mainsdk.dev/docs/interfaces/ai)
- [Playbooks](https://mainsdk.dev/docs/interfaces/playbooks)
