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.md file.
  • 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 filtered by a 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.
KeyWhat it is
(body)The instructions — what the agent should know and do while the context is attached.
descriptionThe 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.
entityWhat the entity key names, in prose (ticket id, client slug). The key itself arrives with the link.
whenThe links it attaches on — § 2. Default [run, chat, reply, ask]: every link but always.
dataState 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.
charsThis context’s budget in characters — § 5.
keepHow 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 by omnislash doctor → Brain entities (content-layout.md § Uniqueness).
  • The frontmatter is strict. A misspelt key or when word 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.md enabled: false switches 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).

whenThe linkIn this engine version
runThe 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
chatThe turn’s chat or thread is bound to the context — statically in access.yaml, or by context_bind — § 2.2.attaches
replyThe incoming message answers a message this feature sent (a HITL card included) — § 2.1.attaches
askA line in the role’s catalog; the agent opens it with context_open when the turn needs it.attaches
alwaysEvery 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:

  1. 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-day

    Only contexts whose when holds run or reply join a layout reach — the two links a reach serves.

  2. 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 in contexts: (§ 3). A scenario-inline hook reaches its scenario’s. Agents set it like any frontmatter key (heartbeat_update / hook_update / scenario_update take reach).

  3. Code, per fire or step. A hook gate answers { reach: [{ context, entity? }] } and a pipeline step calls runAgent(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).

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 sendIts origin
no aboutthe 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 its when must hold reply.
  • It sticks for keep turns 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.

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) — turn is { input, role, run?, surface, subjectId } (run = the feature run the turn is, { feature, reach: [{ context, entity? }] }): input is 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, null not to. An entity on a context that names none is an error.
  • render(entity, ctx) — [{ key, text }]: lowercase identifier keys, none of them instructions or one of the context’s views. A section past the context’s chars is replaced by a one-line stub under its key.
  • ctx = { context, signal, log }. Each call — the module’s load included — has contexts.codeTimeoutMs (§ 5); past it signal aborts. A turn’s match calls run in parallel, then its render calls 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 match that returns nothing included) links nothing and renders nothing; the runtime log says why, and a failed render leaves 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 / ask contexts, whatever the features declare. Doctor lists the roles you wrote without a contexts: 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 run entry of the run’s own feature and an access.yaml binding need no allowlist. A feature already tied itself to the role when its heartbeat, hook or scenario named that role in agent:, and a group rule naming contexts: 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_open opens a context the role admits, one the turn’s run reaches, or one the chat’s access.yaml rule binds (so a bound context the budget stubbed opens from its stub; the binding’s entity still pins it). when decides 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 per ask context 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 an instructions section 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 behind ask (capability context:read, safe, no approval; a role needs it in capabilities: 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.ts renders (§ 2.3) fitted into the context’s chars the way a turn fits them (a section past it is a one-line stub). The context then rides the agent’s next keep turns, its views re-read each turn. entity is 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. Once nova_film is in some context’s data:, 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

ValueEngine defaultBrain-wideOne context
chars — characters one attached context may take, instructions and views together4000constitution/MANIFEST.yaml § contexts.charschars: in the file
keep — turns an inferred link (ask, reply, code) stays attached after the turn that made it; 0 = that turn only3§ contexts.keepkeep: in the file
turnChars — characters all attached contexts of one turn may take together12000§ contexts.turnChars— (a whole turn is no one context’s)
maxActive — contexts one turn may carry at once4§ contexts.maxActive—
codeTimeoutMs — milliseconds one call of a *.context.ts (match, render) may take2000§ 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 the context-stubbed finding with the knob to raise (contexts.turnChars, contexts.maxActive, or the context’s own chars) — or trim the context.
  • /context in 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.md that does not load: bad frontmatter, an unknown key or when word, a duplicate id.
  • Feature context code — a *.context.ts that does not load or does not default-export defineContext({ match?, render? }) (fail: it links and renders nothing on every turn), and one with no *.context.md beside it (warn).
  • Chat access — an access.yaml binding (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:); a reach: naming a context that does not exist; ask without a description; a view claimed by two contexts (two features now own one view — keep it in one, or split it); a data: view that does not exist, or a by column the view lacks; a role’s contexts: naming a context that does not exist; a role that admits an ask context but cannot call context_open (no context:read and no '*' — it gets no catalog line); a { view, by } entry on a context whose when has no link that names a key — always carries none, and run carries one only for a context in the reach of a gated hook (its gate’s { entity } / { reach }) or a pipeline (its runAgent) — so the view shows rows only when an engage() attach names one (add reply or ask, gate a hook, or drop by). A note lists which of your roles have no contexts: 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_check inside 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).

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.