---
title: "A Playbook Per Tier"
description: "Use defineDynamic and defineSkill to load a different shop playbook per membership tier, resolved from the caller's authenticated identity at session start."
canonical_url: "https://vercel.com/academy/building-agents-with-eve/a-playbook-per-tier"
md_url: "https://vercel.com/academy/building-agents-with-eve/a-playbook-per-tier.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-08-08T22:50:08.955Z"
content_type: "lesson"
course: "building-agents-with-eve"
course_title: "Building Agents with eve"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# A Playbook Per Tier

# A Playbook Per Tier

A good front-desk advisor keeps the explanation simple for a first-time customer. With a shop regular who works on their own bikes, they can discuss torque specs and part numbers. The person behind the counter is the same; the playbook changes with the customer.

We could try to cram all of that into `instructions.md`, but that gets messy: every caller would carry every tier's rules on every turn, and a walk-in would somehow know about the pro discount. What we want is a procedure that loads *only* for the caller it applies to, and only when it's relevant.

A **skill** is a Markdown procedure the model loads on demand. Ours needs to be chosen per caller, so we'll make it a **dynamic** skill that resolves at the start of each session.

## Outcome

The dispatcher loads a member or pro playbook based on the caller's tier, while a walk-in gets the plain desk, none the wiser about either.

## Hands-on exercise

**Build the dynamic skill.** Create `agent/skills/shop-playbook.ts`. A dynamic capability is a resolver: it runs on a session event and returns a capability, or `null` for none. Ours runs on `session.started`, reads the caller's `tier`, and hands back the matching playbook as a skill:

```ts title="agent/skills/shop-playbook.ts"
import { defineDynamic, defineSkill } from "eve/skills";

const PLAYBOOKS: Record<string, { title: string; markdown: string }> = {
  pro: {
    title: "Pro / shop-mechanic playbook",
    markdown:
      "This caller is a pro mechanic. Talk torque specs and part numbers freely, " +
      "skip the absolute basics, and recommend a Full Overhaul when the symptoms " +
      "justify it. Reference /workspace/torque-specs.md for fastener values.",
  },
  member: {
    title: "Member playbook",
    markdown:
      "This caller is a shop member. Mention the 10% labor discount on bookings, " +
      "and offer a free loaner bike whenever a job will keep their bike overnight.",
  },
};

export default defineDynamic({
  events: {
    "session.started": async (_event, ctx) => {
      const tier = ctx.session.auth.current?.attributes.tier;
      const key = Array.isArray(tier) ? tier[0] : tier;
      const playbook = key ? PLAYBOOKS[key] : undefined;
      if (!playbook) return null; // no tier → no playbook, just the standard desk
      return defineSkill({
        description:
          `Use when serving a ${key}-tier customer. ` +
          `Contains that tier's standing conventions.`,
        markdown: `# ${playbook.title}\n\n${playbook.markdown}`,
      });
    },
  },
});
```

The pro playbook points at `/workspace/torque-specs.md`, a reference file the agent can open with its file tools when a pro asks for fastener values. Create it so there's something to read:

```md title="agent/sandbox/workspace/torque-specs.md"
# Spoke & Mirror torque reference

Fastener values in newton-meters (Nm). When in doubt, start at the low end and check.

| Fastener           | Torque (Nm) |
| ------------------ | ----------- |
| Disc rotor bolts   | 6           |
| Stem faceplate     | 5           |
| Seatpost clamp     | 5           |
| Crank arm (2-bolt) | 12          |
| Cassette lockring  | 40          |
| Pedal into crank   | 35          |
```

Anything under `agent/sandbox/workspace/` is seeded into the agent's sandbox at `/workspace/` when a session boots. Section 5 returns to the sandbox for deployment.

**Give yourself a way to test it.** Here's the catch: the skill reads `tier` from the caller's *authenticated identity*, and right now nothing sets one. In the TUI you'll always get the plain desk. So add a small testing door in `agent/channels/eve.ts` that stamps a tier from a header:

```ts title="agent/channels/eve.ts"
import { eveChannel } from "eve/channels/eve";
import { localDev, vercelOidc, type AuthFn } from "eve/channels/auth";

// TEMPORARY testing door: read a tier from a header so we can exercise the
// per-tier playbook locally. We replace this with a real auth policy in 4.3.
const demoTierAuth: AuthFn<Request> = async (request) => {
  const tier = request.headers.get("x-shop-tier");
  if (!tier) return null;
  return {
    attributes: { tier },
    principalType: "user",
    principalId: "demo-customer",
    authenticator: "demo",
  };
};

export default eveChannel({
  auth: [demoTierAuth, localDev(), vercelOidc()],
});
```

\*\*Warning: Keep \`localDev()\` in the chain\*\*

The eve channel fails closed: if no entry in the `auth` array admits a request, the route returns `Authorization is required for this route`. `localDev()` is the entry that admits the local TUI. If you merge `demoTierAuth` into your existing `eve.ts` by hand instead of replacing the file, don't drop `localDev()`, or the TUI will lock you out on the very next message. `demoTierAuth` only matches requests that carry an `x-shop-tier` header, which the TUI never sends.

\*\*Warning: The tier comes from auth, not from the chat\*\*

This is the whole point of keying on `ctx.session.auth`. A walk-in can't *talk* their way into the pro discount by saying "I'm a pro", the tier is a claim the door stamps, not text the model reads. The demo door fakes that claim from a header so you can test. In 4.3 you'll swap it for auth that earns the claim honestly.

## Try It

In the plain TUI, no tier is set, so it's the standard desk:

```text
you › my rear shifting is sloppy, what do you suggest?
dispatcher › Sounds like the derailleur needs adjusting. A Basic Tune-Up ($65)
covers that. Want me to check openings?
```

Now call over HTTP as a pro by sending the header, and watch the tone change:

```bash
curl -X POST http://127.0.0.1:2000/eve/v1/session \
  -H 'content-type: application/json' \
  -H 'x-shop-tier: pro' \
  -d '{"message":"rear shifting is sloppy, what do you suggest?"}'
```

```text
dispatcher › Indexing's drifted, most likely. I'd check the B-tension and hanger
alignment before anything else. If the cassette's worn past spec it's a Full
Overhaul; torque the cassette lockring to spec (see the torque sheet). Want the
overhaul booked?
```

The pro response includes torque specs, part-level detail, and an overhaul recommendation because the resolver loaded the `pro` playbook. Try `x-shop-tier: member` to hear about the labor discount and loaner bike instead.

\*\*Note: Why eve info shows zero skills\*\*

Run `eve info` and the skill count is `0`, as expected. A dynamic skill doesn't exist until a session resolves it. The file contains a resolver that may produce a skill depending on the caller.

Plain desk no matter what header you send? Two checks: the resolver must read `ctx.session.auth.current?.attributes.tier` (not the message), and your `eve.ts` must list `demoTierAuth` in the `auth` array. If `eve info` reports a discovery error, make sure `shop-playbook.ts` default-exports the `defineDynamic(...)` result.

## Done-When

- [ ] `agent/skills/shop-playbook.ts` default-exports `defineDynamic` resolving on `session.started`.
- [ ] With no tier, the resolver returns `null` and the agent runs the plain desk.
- [ ] Sending `x-shop-tier: pro` (or `member`) over HTTP produces the matching playbook's behavior.
- [ ] You can explain why the tier must come from auth, not from the user's message.

## Solution

The full `shop-playbook.ts` and the demo `eve.ts` are both shown above. The shape worth remembering: `defineDynamic` is a resolver keyed on a session event, and the same pattern also drives dynamic *tools* and *instructions*. Resolve on `session.started` for a per-session decision, then return a capability or `null`.

One loose end remains: this `eve.ts` trusts a header anyone could send. That is useful for a local test and unsafe for production. Section 4 adds a web dashboard and Slack, then replaces this file with an auth policy that derives `tier` from a verified caller.


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
