Feature contexts — what a feature brings to a turn linked to it
A role is the one place an agent’s prompt used to come from. A feature could run entities as a role (a heartbeat, a hook, a scenario), but it could not add anything to a turn. So every feature’s rules and data went into the role: the role body grew by one feature at a time, every turn carried every feature, and a heartbeat’s run paid for the rules of every other feature the same role served.
A feature context fixes that. It is a file the feature owns —
features/<feature>/<name>.context.md — with the feature’s instructions in the body
and its data (state views) in the frontmatter. The engine attaches it to a turn when
the turn has a link to it: the turn is a run of an entity that reaches it, the
message answers one sent by a run that reached it, the chat is about it, the role
always carries it, or the agent opened it from its catalog. A turn with no link pays
nothing for it.
Four words you will see below:
- context — one
*.context.mdfile. - link — the fact that ties a turn to a context: the turn is a run that reaches it, the message answers such a run’s post, the agent opened it, the chat is bound to it.
- reach — the contexts one run reaches, each with its entity key (§ 2): by default the contexts in the running entity’s own directory and above, up to the feature root.
- entity key — an opaque string a link can carry (
T-481,acme,p_912): the one ticket, client or prospect the turn is about. A view filteredbya column shows only that entity’s rows.
1. The file
---
# features/support/ticket.context.md — id `ticket` (the file name), unique in the brain
description: A support ticket — tone, SLA, escalation. Not for billing questions.
entity: ticket id
when: [run, ask]
data:
- support_sla_today # the view, whole
- { view: support_ticket_card, by: ticket_id } # WHERE ticket_id = <the entity key>
chars: 3000
---
Answer as the support desk. A refund over $100 goes through hitl_request_approval —
never promise one in the reply. Quote the SLA from the card, not from memory.
| Key | What it is |
|---|---|
| (body) | The instructions — what the agent should know and do while the context is attached. |
description | The catalog line (≤ 250 characters): what the context is for, and when it is not. Optional — a context nobody opens by hand (run / always only) needs none — but an ask context without one is a bare name in the catalog, and doctor warns. |
entity | What the entity key names, in prose (ticket id, client slug). The key itself arrives with the link. |
when | The links it attaches on — § 2. Default [run, chat, reply, ask]: every link but always. |
data | State views it carries: a view name (the view whole), or { view, by } (only the rows whose by column equals the entity key). Bare Postgres identifiers. |
chars | This context’s budget in characters — § 5. |
keep | How many turns an inferred link (ask, reply, code) keeps it attached after the turn that made it — § 5. |
- The id is the file name (
ticket.context.md→ticket), and it is unique across the whole brain: two files of one name, even in two features, load neither — each becomes a skip naming the other, reported byomnislash doctor→ Brain entities (content-layout.md § Uniqueness). - The frontmatter is strict. A misspelt key or
whenword fails the file’s load with a did-you-mean, rather than leaving a context that quietly does less than you wrote.when: []is refused: a context is switched off with its feature. - A context has no
enabled:of its own.FEATURE.mdenabled: falseswitches the feature off, and every context of it with it. - Contexts are content, like roles: an edit reaches the next turn, no restart; a
workflow run pinned to a commit reads them from its checkout. How often the engine
re-checks the files is
contentProbe.context(content-layout.md § How often the engine re-reads your content).
2. When it attaches — when
A closed vocabulary, strongest link first. When too many contexts compete for one turn’s budget, the weakest links are the ones cut (§ 5).
when | The link | In this engine version |
|---|---|---|
run | The turn is a run whose reach holds this context — a heartbeat tick, a hook fire, a scenario’s task agent, a pipeline’s runAgent (see The reach below). Each reach entry carries its own entity key (§ 2.3). | attaches |
chat | The turn’s chat or thread is bound to the context — statically in access.yaml, or by context_bind — § 2.2. | attaches |
reply | The incoming message answers a message this feature sent (a HITL card included) — § 2.1. | attaches |
ask | A line in the role’s catalog; the agent opens it with context_open when the turn needs it. | attaches |
always | Every turn of a role whose contexts: admits it. | attaches |
The reach — which runs a context reaches. Every run carries the list of contexts it reaches, each with its entity key, decided once where the run starts. Three tiers, each replacing the one below:
-
The layout (default). A context reaches every heartbeat, hook and scenario in its own directory and below; a run reaches the contexts of its directory and every directory above it, up to the feature root. A context at the feature root reaches the whole feature. A subfolder is a part of the feature with its own contexts — so a feature can be deep:
features/largo/ largo.context.md # every run in largo/ morning/checkin/checkin.context.md # only the beats in morning/checkin/ evening/film/film.context.md # only film-of-dayOnly contexts whose
whenholdsrunorreplyjoin a layout reach — the two links a reach serves. -
reach:on the entity. A heartbeat, hook or scenario lists exactly the contexts its runs reach —reach: [film, checkin]— replacing the layout;reach: []reaches none. It may name a context of another feature: such an entry attaches only where the turn’s role admits it incontexts:(§ 3). A scenario-inline hook reaches its scenario’s. Agents set it like any frontmatter key (heartbeat_update/hook_update/scenario_updatetakereach). -
Code, per fire or step. A hook gate answers
{ reach: [{ context, entity? }] }and a pipeline step callsrunAgent(ctx, { reach })— replacing the run’s reach for that fire or step (§ 2.3). For what only the event knows: which part a fire is about.
when still decides what attaches: a reach is never a way around it. A when: [reply]
context in the reach does not attach on the run — it comes back on a reply to the run’s
messages (§ 2.1). Moving a context into a subfolder takes it out of its siblings’ runs;
doctor names a run context no entity reaches (§ 7).
2.1 How reply links
Every message the bot sends from a run carries its origin — the run’s feature and
the contexts a reply should bring back (its reach, each entry with its entity key):
a heartbeat’s or hook’s post, a hook’s outcome notice, the answer a feature run leaves
in a chat, every HITL card a run raised (a pipeline’s requestApproval /
awaitApproval included), and anything sent with about (notification_send,
file_send, voice_send, context.notify, a pipeline’s notify(), requestApproval
and awaitApproval — notifications.md). Engine chrome (command
answers, run cards, status notices) belongs to no feature.
What a send’s origin carries:
| The send | Its origin |
|---|---|
no about | the run’s reach, every entry with its key |
about: { entity: 'F-7' } | the run’s reach with its ONE keyed context keyed F-7 — refused off a run, and when the reach holds no keyed context or more than one (name the context then) |
about: { context: 'ticket', entity: 'T-481' } | that context alone (and its feature) with that key — nothing inherited |
about: { context: 'film' } | that context alone, no key — the way to send a message about no one entity |
A run with no key cannot send unkeyed what would come back keyed. When every
context of the run’s reach whose when holds reply declares an entity: and carries
no key, and the send names no about, the send is refused — the refusal names each
context and what its key is ("film" (keyed by film id)). The film of the day is chosen
inside the beat’s turn, and its id exists only once the turn wrote it, so no dispatcher
could have set the key before the run: the agent sets it in the send,
about: { entity: '<film id>' }, deterministically, instead of the reply coming back
without it. about: { context: 'film' } opts out.
When a person replies to such a message (a native reply that quotes it), the turn
that answers carries a reply link to it: the message’s recorded reach, each context
whose when holds reply with its entity key — a keyed ticket entry T-481 attaches
ticket filtered to T-481. It is the mirror of run: the reply sees what the run saw,
and a when: [reply] context the run carried in its reach comes back here.
- A reply is an inferred link: the role’s
contexts:must admit the context (§ 3), and itswhenmust holdreply. - It sticks for
keepturns after the one that answered the reply (§ 5) — the follow-up “and tomorrow?” still sees the context. A second reply to the same feature restarts the count. - Several messages merged into one turn carry every link any of them made.
A reply to a message older than MANIFEST messageOrigins.retentionDays (a year by
default, 0 = forever) links to nothing, and so does a reply to a message of a chat
answer that was not a feature run. A HITL card still waiting for an answer keeps its rows
at any age — a reply to it is always routed to the card — and so does a widget’s chat-lines
message (widgets.md § 5): a reply to it always reads as the page’s lines.
2.2 How chat links — binding a chat to an entity
A chat (or one thread of it) can be about one entity: a client’s group, a ticket’s forum topic, a prospect’s chat. Bind it, and every turn there carries the context with that entity’s key. Two ways:
Statically, in the chat rules — a group rule’s contexts: in
constitution/access.yaml (chat-access.md): context id → entity
key (no value for a context that names no entity).
bots:
nova:
chats:
"tg:chat:-1001234567890":
role: client-desk
title: Acme
contexts: { client: acme }
Only a group rule carries contexts: — a DM or bot-default rule is a template for many
chats, and a chat is about one entity. It is an explicit link, like run: the
role’s contexts: allowlist does not apply. Adding or moving a binding is a widening
change to the file (that entity’s data starts reaching the room), so access_update
refuses it and access_widen asks an owner.
At run time, with a tool — context_bind({ context, entity?, chat?, thread?, bot? })
and context_unbind({ context, chat?, thread?, bot? }) (capability context:bind,
moderate, no approval by default). chat defaults to the chat the turn is in, thread
to the whole chat, bot to every bot of the brain; binding the same context again in
the same place replaces the entity. Pipelines call the same tools through
bindContext(ctx, { chat, context, entity }) / unbindContext(...)
(pipelines.md). A tool binding is an inferred link — the turn’s
role must list the context in contexts:. The tool refuses an unknown context, a
missing entity on a context keyed by one (and one given to a context that is not), and
a binding that contradicts the chat’s access.yaml binding — under any bot’s rule for
the chat, whatever bot the binding is for; context_unbind refuses a binding that lives
in access.yaml (edit the rule instead).
# a hook on a new ticket opens a forum topic, then binds it:
context_bind({ context: 'ticket', entity: 'T-481', chat: 'tg:chat:-100…', thread: '<topic id>' })
When one chat has several bindings of one context, the strongest wins: access.yaml,
then a binding for the turn’s thread, then one for the whole chat (and one for the
turn’s bot over one for every bot). The context’s when must hold chat for the binding
to attach it; a binding of a context without chat still pins it (below).
access_explain lists a chat’s bindings. A Telegram Business chat carries none.
The pin — tenant isolation. A chat bound to client@acme attaches client only
with the key acme, whatever the link: a reply to a message about globex, an
ask, a held open, a run. A link that names another key is refused (and logged); one
that names none takes acme. context_open('client', 'globex') in the acme group is
refused with a message saying why; context_open('client') opens it for acme. The
pin holds for every bot in the room: one bot’s access.yaml rule binding the chat pins
it for the other bots’ turns there too (it attaches only for the bot whose rule names
it). A keyed context bound with no entity (client: with no value) pins it to none —
every client with a key is refused there. Only views filtered by the entity key are
scoped this way — a view a context carries whole shows its whole rows in every chat, so
keep tenant data behind by.
Keywords, time windows and pulling an id out of the message text are not when
words, and will not become ones: that is brain code, not a growing vocabulary — § 2.3.
2.3 Brain code — *.context.ts, engage() attach, a hook gate’s and runAgent’s reach
Three places where your code links a turn to a context. The link they make is called
code and ranks between reply and ask. It is inferred: the role’s contexts:
must admit the context, a chat pin holds it to the bound key (§ 2.2), and it stays
attached for the context’s keep turns. It does not need its word in when — code is
a decision of its own.
<id>.context.ts — the code half of <id>.context.md, beside it, under the same
id (like a hook’s gate beside its hook). The .md stays the declaration; a .ts with
no .md beside it is never loaded (doctor Feature context code names it).
// features/support/ticket.context.ts
import { defineContext } from '@omnislash-ai/config/public';
export default defineContext({
// Asked on every turn of a role that admits `ticket`: link it when the input names one.
match: turn => {
const id = /\bT-\d+\b/.exec(turn.input)?.[0];
return id ? { entity: id } : null;
},
// Sections after the context's views, inside what is left of its `chars`.
render: async (entity, ctx) => [{ key: 'sla', text: await slaLine(entity, ctx.signal) }],
});
match(turn, ctx)—turnis{ input, role, run?, surface, subjectId }(run= the feature run the turn is,{ feature, reach: [{ context, entity? }] }):inputis the turn’s input as the agent reads it (a chat message with its envelope and the room’s lines before it, a hook’s rendered prompt). Answer{ entity? }to link,nullnot to. Anentityon a context that names none is an error.render(entity, ctx)—[{ key, text }]: lowercase identifier keys, none of theminstructionsor one of the context’s views. A section past the context’scharsis replaced by a one-line stub under its key.ctx={ context, signal, log }. Each call — the module’s load included — hascontexts.codeTimeoutMs(§ 5); past itsignalaborts. A turn’smatchcalls run in parallel, then itsrendercalls do, so code adds at most twice that to the turn.- It fails closed. A module that does not load, a call that throws or times out, an
answer of the wrong shape (a
matchthat returns nothing included) links nothing and renders nothing; the runtime log says why, and a failedrenderleaves a line in the turn saying its sections are missing. Both run once per turn, before the model call, inside the turn’s journaled preparation (never again on a replay). - The file is hot like the context: an edit reaches the next turn. As with every brain module, a sibling it imports reloads only on restart (self-evolving.md).
attach in the engage() verdict — the bot’s chat policy links the turn it engages
(engagement.md):
engage: s => {
const id = s.text?.match(/\bT-\d+\b/)?.[0];
return id ? { engage: true, attach: [{ context: 'ticket', entity: id }] } : undefined;
},
An entry that is not { context, entity? } (an empty id, an empty or over-long entity), a
context that does not exist, or an entity on one that names none, is dropped with a
logged reason — the turn still runs. Several messages merged into one turn carry every message’s attach.
{ reach } / { entity } from a hook gate — a hook’s gate decides what the fire is
a run of. ctx.reach is the hook’s own reach (its reach:, else its directory and
above); return { reach: [{ context: 'listen' }] } replaces it for this fire, and
return { entity: event.ticketId } keys the reach’s ONE keyed context — both with or
without vars. The fire’s run link then carries those entries: keyed contexts attach
with their key, their by views filtered to it (contract.md § 3, Hook
gates). An { entity } the reach cannot take — no keyed context, or two — fails the fire,
naming them; name each key in reach then. A run entry of the hook’s own feature needs
no allowlist; a chat pin still applies.
reach / entity on a pipeline’s runAgent — runAgent(ctx, { agent, prompt, entity: 'p_912' }) keys the step’s reach (the scenario’s) exactly like a gate’s
{ entity }, and runAgent(ctx, { ..., reach: [{ context: 'prospect', entity: 'p_912' }] })
replaces it — a cheap step can drop context, a step about one part names that part. What
the agent sends carries the step’s reach. A step of an invocation whose scenario has no
feature fails with TerminalError (pipelines.md).
A heartbeat tick and a scenario’s task agents carry their reach with no key: a beat is a
schedule, not one entity. A key that is decided inside the turn is set where the turn
speaks — about: { entity } on its send (§ 2.1).
3. Which roles it can reach — contexts:
A role lists the contexts that may reach it, like skills::
# features/support/support.agent.md
contexts: [ticket, refunds] # only these two
# contexts: ['*'] # every context in the brain
# no `contexts:` (or []) # none — fail-closed
- No key is no contexts. A role that never names one gets none of the
always/askcontexts, whatever the features declare. Doctor lists the roles you wrote without acontexts:list, so the gap is visible. reply,ask, code (§ 2.3) and a tool binding (context_bind) are inferred — the role must admit the context. A reply to a feature’s post in a chat whose role does not list that feature’s context attaches nothing.- A
runentry of the run’s own feature and anaccess.yamlbinding need no allowlist. A feature already tied itself to the role when its heartbeat, hook or scenario named that role inagent:, and a group rule namingcontexts:ties the chat to them — explicit links, so they attach on any role. A reach entry naming another feature’s context has no such tie: it attaches only where the role admits it. context_openopens a context the role admits, one the turn’s run reaches, or one the chat’saccess.yamlrule binds (so a bound context the budget stubbed opens from its stub; the binding’s entity still pins it).whendecides what attaches on its own and what the catalog lists; an open is the agent’s explicit ask.- A name in
contexts:that no context answers to is a doctor warning with the nearest real name.
4. What the agent sees
- The catalog — in the system prompt, one
- <name>: <description>line peraskcontext the role admits. It is stable per role (it never changes from turn to turn), so it does not break the prompt cache. - The attached contexts — at the tail of the turn, never in the system prompt: one
<feature-context>block per attached context (per context and entity key — two tickets are two blocks), naming the feature, the entity and the link that attached it, with aninstructionssection and one section per view.- On a CLI agent the session keeps what it was sent: the instructions arrive once, a view is re-sent only when its rows change, a context that is no longer attached gets a one-line withdrawal, and after a compaction the attached set is sent again whole.
- On an API agent the block is rendered fresh into each turn.
context_open(name, entity?)— the tool behindask(capabilitycontext:read, safe, no approval; a role needs it incapabilities:like any other tool — without it the role gets no catalog either, and doctor warns). It answers at once with what the tail would carry: the instructions, then the views as of now and the sections its*.context.tsrenders (§ 2.3) fitted into the context’scharsthe way a turn fits them (a section past it is a one-line stub). The context then rides the agent’s nextkeepturns, its views re-read each turn.entityis for a context that names one (entity:in its file); a key for a context that names none is refused. On a turn replaying a workflow run pinned to a commit, the tool reads that commit’s contexts and role, like the dispatch.- A view a context claims leaves the role’s
<agent-state>block. Oncenova_filmis in some context’sdata:, it travels only with that context — the role’s state block stops rendering it, and it no longer spends a slot of the state budget there (state.md § 2).
5. Budgets — three tiers
| Value | Engine default | Brain-wide | One context |
|---|---|---|---|
chars — characters one attached context may take, instructions and views together | 4000 | constitution/MANIFEST.yaml § contexts.chars | chars: in the file |
keep — turns an inferred link (ask, reply, code) stays attached after the turn that made it; 0 = that turn only | 3 | § contexts.keep | keep: in the file |
turnChars — characters all attached contexts of one turn may take together | 12000 | § contexts.turnChars | — (a whole turn is no one context’s) |
maxActive — contexts one turn may carry at once | 4 | § contexts.maxActive | — |
codeTimeoutMs — milliseconds one call of a *.context.ts (match, render) may take | 2000 | § contexts.codeTimeoutMs | — (the brain’s bound on its code) |
# constitution/MANIFEST.yaml
contexts:
chars: 3000
maxActive: 3
The nearest tier that names a key wins it. Nothing is dropped silently: within a
context’s chars the instructions come first and its views are cut by rows (a marker
says more exist), then a view that cannot show a row is stubbed in its place. A context
whose instructions alone are over its chars — or one the turn has no room for (past
maxActive or turnChars, weakest link first) — arrives as a one-line stub, not
attached — budget; context_open it to read it (or, for a role that cannot call
context_open, which knob to raise), so the agent knows it exists and how to get it.
Instructions are never cut mid-text: half a rule reads as the whole rule.
state.rows (MANIFEST) is the rows a context view shows; at 0 a view says its rows
are switched off. A view may not be named instructions — that is the key of the
context’s own body.
5.1 Seeing what attached — and what it cost
Every turn records the contexts its tail carried on the spend ledger: each attached
one with its link (run, chat, reply, code, ask, always), its entity key
and its rendered size in characters, and each one a budget stubbed with the knob that
kept it out. Three places read it back:
agent_context(the dashboard’s System → Context, section Feature contexts carried) — per role over the window: how many of its turns each context attached to and how often it was stubbed, its mean size, the links that brought it. A context stubbed in the window raises thecontext-stubbedfinding with the knob to raise (contexts.turnChars,contexts.maxActive, or the context’s ownchars) — or trim the context./contextin a chat — the Contexts line: what the agent’s latest turn carried.- The size is the rendered context, not what a CLI session re-sent: instructions ride once per session and views only when they change, so the bill per turn is usually smaller than the size — the size is what you can change.
6. Examples
The same mechanism serves very different brains. Pick the link that is true for your case — a link beats a description the model has to notice.
A freelancer’s invoice chase — run. The invoice-chase heartbeat needs the
overdue invoices; the owner’s chat does not.
---
# features/billing/chase.context.md
when: [run]
data: [billing_overdue]
---
Chase politely: one reminder per invoice per week. Above 30 days overdue, draft the
message and ask the owner before sending.
Every tick of a billing heartbeat carries it; the chat role no longer carries the
view on every message.
An e-commerce return policy — ask. Long rules, rarely needed.
---
# features/returns/policy.context.md
description: Return and refund rules — eligibility, windows, who pays shipping. Not for order status.
when: [ask]
---
…the policy…
The support role lists it (contexts: [policy]) and holds context:read: one catalog
line on every turn, the body only when a customer asks about a return.
A family group’s shopping list — always. Small, and every message may touch it.
---
# features/home/shopping.context.md
description: The open shopping list.
when: [always]
data: [home_shopping_open]
---
Add, tick off and read back items. Never delete an item someone else added without saying so.
Same effect as putting it in the role, but the feature owns it, it has a budget, and switching the feature off removes it.
A coach persona’s daily pick — run, then reply with the film’s key. The beat
sits in features/coach/evening/film/ beside its two contexts, so its reach holds both
and no other beat of coach/ sees them. The pick context is when: [run]: only the
beat’s run sees the pick view, not every chat message. The rating context —
entity: film id, when: [reply], data: [{ view: coach_film, by: film_id }] — rides
the reach without attaching on the run, and is keyed, so the beat, which chooses the
film inside its turn, posts it with
notification_send({ ..., about: { entity: '<the new film id>' } }) (a post with no
about is refused, naming film). When the owner answers that post with “8/10” (the
chat role lists the rating context in contexts:), the reply carries the film’s rows
and stays for the next keep turns; ordinary chat never sees it.
A language coach with four daily practices — one feature, four parts.
features/dutch/ holds the coach’s role; knm/, listen/, write/ and read/ each
hold a practice’s scenario, pipeline and context. Each practice’s runs reach its own
context only, and a reply to its card brings that one back. The one hook at the feature
root that handles “skip” for every practice answers { reach: [{ context: event.skill }] }
from its gate — the event knows which part it is about.
A support desk with a ticket per thread — chat with an entity key. The
ticket.context.md from § 1 with when: [chat, ask]. The ticket-opened hook opens a
forum topic and calls context_bind({ context: 'ticket', entity: 'T-481', thread });
the support role lists contexts: [ticket, kb]. Every turn in that topic carries
ticket T-481’s card (by: ticket_id) — even when the customer writes without quoting
anything — and no other ticket’s.
B2B outreach, an agent per prospect chat — chat from a pipeline.
prospect.context.md — when: [chat, run], data: [{ view: outreach_prospect, by: prospect_id }]. After the first touch the pipeline calls
bindContext(ctx, { chat, context: 'prospect', entity: 'p_912' }); the sdr role lists
contexts: [prospect, offer]. The pipeline’s own drafting step runs
runAgent(ctx, { ..., entity: 'p_912' }), so its run link shows the same prospect’s
rows, and the draft it sends comes back keyed when the owner replies to it.
An agency with a group per client — isolation by entity key. A client.context.md
with when: [chat, reply, ask] and data: [{ view: agency_client_card, by: client_slug }]; access.yaml binds each client’s group (contexts: { client: acme }).
The client-desk role lists contexts: [client] and nothing else, so hr and
finance contexts never reach a client group; the pin keeps each group on its own
client’s rows — context_open('client', 'globex') in the acme group is refused.
7. Checking it — omnislash doctor
- Brain entities — a
*.context.mdthat does not load: bad frontmatter, an unknown key orwhenword, a duplicate id. - Feature context code — a
*.context.tsthat does not load or does not default-exportdefineContext({ match?, render? })(fail: it links and renders nothing on every turn), and one with no*.context.mdbeside it (warn). - Chat access — an
access.yamlbinding (contexts:) naming a context that does not exist, without an entity key on a context keyed by one, or with a key on one that is not. - Feature contexts — a context that loads but cannot do what you wrote: no role
lists it and no entity’s reach holds it (it reaches no turn — move it beside the
entity it is for, or name it in that entity’s
reach:); areach:naming a context that does not exist;askwithout adescription; a view claimed by two contexts (two features now own one view — keep it in one, or split it); adata:view that does not exist, or abycolumn the view lacks; a role’scontexts:naming a context that does not exist; a role that admits anaskcontext but cannot callcontext_open(nocontext:readand no'*'— it gets no catalog line); a{ view, by }entry on a context whosewhenhas no link that names a key —alwayscarries none, andruncarries one only for a context in the reach of a gated hook (its gate’s{ entity }/{ reach }) or a pipeline (itsrunAgent) — so the view shows rows only when anengage()attachnames one (addreplyorask, gate a hook, or dropby). A note lists which of your roles have nocontexts:list. The view checks need the deployment’s database: from a laptop with no route to it the check says it did not look;doctor_checkinside the deployment does.
8. What stays where it was
- The role body keeps the persona — who the agent is and how it talks. A rule that belongs to one feature belongs in that feature’s context.
state:keeps the persona’s own data. A view one feature owns moves into that feature’s context (§ 4).- Skills stay skills. A context without data and
when: [ask]is close to a skill; the difference is the link — a context arrives with a run of its feature, can carry views filtered to one entity, and is owned by a feature you switch as one. - Contexts are text, not rights. A context never grants a tool; what the agent may
do is still the role’s
capabilities:(contract.md § 7.3).