Mounted maps
An agent can be fed by more than one walkable map. Each map owns its cursor; the mount kernel owns only engagement — whether a map's prompt fragment and tools ride the next call at all.
What ships today
The kernel mounts with .maps() on top of a skill map and is live. Exactly one map mounts today (the skill map); the data model is already per-map, so the second tenant does not reshape it.
The failure this exists for
A recorded turn ran 30 calls and 359,000 tokens. At the start, an entry pattern matched the word "zone" inside "find the most recent zone redundancy run" — a noun the person wanted to find, not a task they wanted done. That match put the cursor on an audit skill.
Nothing was broken. The map behaved exactly as written. The audit skill's four tools were never called once; 29 of the 30 cursor moves were "stay"; and roughly 7,000 characters of the wrong skill rode every single call to the end of the turn.
The gap was that nobody owned the question "is this contribution still earning its place?" A map knows where its cursor is. No map is in a position to judge whether it should be talking at all.
The one idea
Split the two axes that were tangled together:
- Position is the map's. Which node the cursor is on. The map decides this and never surrenders it.
- Engagement is the kernel's. Whether that map's prompt fragment and tools ride the next model call.
The law in one sentence: an engagement founded on a guess is renewed only by concrete evidence, and without corroboration it is parked — its cursor untouched.
Parking is not a rollback and not a cursor move. The map stays exactly where it was. It simply stops talking until something real justifies it talking again.
Mounting it
import { Agent } from 'agentfootprint';
const agent = Agent.create({ provider, model })
.skillGraph(myMap)
.maps({ renewalGrace: 3 })
.build();.maps() takes MapsOptions. The one dial today is renewalGrace — how many corroboration-free passes a guessed engagement survives before it parks. It defaults to 3 and must be an integer of at least 1.
Mounting requires a skill map: with nothing mounted there is nothing to engage or park, so the builder refuses rather than mounting a kernel over an empty set.
Pass nonParkable: true to mount the map as mandatory — it is never parked, whatever the idle count. For a policy map, a compliance map, a safety brief: content whose absence is a defect even when the turn never touches it. Note what it does not do: a mandatory map still accrues idle, and a tentative standing still reads tentative. It suppresses the park, not the measurement — so the record still shows you a map riding every call unused.
Mounting also requires a map that can explain its cursor moves. The kernel decides engagement by why the cursor is where it is — a declared route is system evidence, a keyword match is a guess — so a graph with no explainNextSkill gives it nothing to weigh, and mounting is refused rather than reduced to a timer over a fact it never learns. Every graph skillGraph().build() produces supplies the clause; only a hand-written structurally-typed graph can hit this.
When you do not call .maps(), nothing changes: no scope key, no events, and byte-identical evaluation. The kernel is opt-in.
How strong is the evidence?
Every engagement carries the strength of what founded it. EvidenceStrength is the ladder, and it decides decay:
explicit— the model or a person asked for this map by name.structural— a declared edge fired, or the map's own tool was called.semantic— a classifier or scorer judged the person's words.lexical— a pattern matched the person's words. A regex hit is a guess about intent, never a commitment.assumed— nobody said why. The cursor is inside the map and no clause explains how it got there. The weakest rung, and the honest answer where the old code wrote down the strongest one.
The top two stand until released. The bottom three are guesses, and guesses expire without corroboration — that is what isTentative answers. strengthOfMove maps a map's own cursor-move vocabulary onto this ladder, strengthOfEvidence does the same for a piece of renewal evidence, and strongestOf picks the winner when several arrive on one pass.
The reason the ladder exists at all is the recorded turn above: a lexical match is exactly what put the wrong map on the wire, and treating it as equal to a user request is what kept it there.
What renews an engagement
Each pass, the kernel asks what actually happened. renewalEvidenceOf reads an EngagementPass — the pass's cursor position, the clause that moved it, the tool results that came back, and the skill picks the gate accepted — and returns the RenewalEvidence it found. Three things count:
- one of the map's own tools was called,
- a declared route fired inside the map,
- the model asked for one of its members by name.
Anything else is not renewal. In particular, a pass where the cursor merely stayed put is not evidence of anything.
The idle test
A tentative engagement counts one idle pass when all three of these hold:
- its contribution was actually served on the pass being judged,
- none of its tools was called,
- and the model called something else instead.
All three matter. A pass with no tool calls at all counts nothing — the model was thinking, not ignoring. And a contribution that never reached the wire cannot have been ignored, so it earns no idle either.
At renewalGrace consecutive idle passes, the map parks.
What the model is told
Everything above lands on the record — skip reasons, agentfootprint.map.parked events. The model reads none of it. So while a map is parked, the model sees a compact status card in its system prompt:
Map status (the framework is telling you this; it is not part of the task):
- skill-map — cursor: zone-audit (unchanged) · engagement: PARKED · reason: no recent evidence that it is the right map for this turn · re-engage: available
Its instructions and its tools are not being sent right now. To bring them back,
call read_skill for any of: zone-audit, billing. That is allowed even for the skill
the cursor is already on — it changes ENGAGEMENT (whether this map is sent to you),
not POSITION (where the cursor is). If this map is not what this turn needs, ignore
this and carry on.Cursor and engagement are printed as separate fields on purpose. That is the teaching move: the model is shown the distinction rather than left to infer a contradiction from a skill that is somehow both "where I am" and "not talking". Then the reason, so the state is not arbitrary. Then the way back, as a concrete call — with the one sentence that stops it being read as a cursor move.
A door the model cannot see is not a door. Before this card, re-engagement was reachable in principle and unreachable in practice: nothing told the model that re-picking a skill it appears to already be in means anything at all.
The tools actually leave
Parking stops the prompt fragment and the tools, and until 9.59.0 the second half was only true on one posture.
With scopeTools: false — the default for flat graphs until 10.0.0 — a skill's tools are pre-loaded into the static tool registry and ride from iteration 1 whatever the cursor says. So parking suppressed the fragment and every one of the parked map's tool schemas kept going out. The wire did not merely stay silent about the park; it contradicted it, showing the model tools for a skill whose instructions had just vanished.
Parking now holds those schemas off the wire on its own authority. This is not a change to scopeTools and does not touch the 10.0.0 ledger: the two dials answer different questions. scopeTools asks do this map's tools follow the cursor? Parking asks is this map talking at all? Keeping those apart is the kernel's whole thesis.
Three facts, not one standing
An engagement record answers three different questions, and keeping them apart is the difference between a causal record and a plausible story:
- Engagement provenance — why is this map participating at all?
foundedBy,foundedAtandfoundedOn. Written once and never rewritten. "At iteration 3 the user explicitly activated audit" stays true forever, whatever happens afterwards. - Position evidence — why is the cursor on this member?
at,atByandatSince. The kernel observes position; it never sets it. - Contribution eligibility — why is this member's contribution being served right now?
by. Recomputed every pass.
Only the third may weaken, and only when the subject changes — never because time passed.
What a declared route does
Say the model explicitly asked for audit, and a declared edge then moves the cursor to billing. Nobody asked for billing.
The ruling, and what the code does: billing does not inherit audit's explicit. It entered by a declared transition, so its position evidence is structural, with its own iteration. Eligibility is re-derived from that — not inherited from the member that used to hold the cursor. The engagement's founding cause is untouched: it still says, truthfully and forever, that this engagement began explicitly on audit at iteration 1.
This matters because explicit never decays. Under a single-by model, one explicit pick at the top of a turn silently warranted every skill the graph walked to afterwards, for the rest of the turn. Now a member entered by a weak clause is tentative and can park, exactly as if it had been entered that way from the start.
The kernel says so on the record too: the agentfootprint.map.engaged event carries tenantChanged, the member moved to, and the founding cause — so a reader sees that eligibility changed and why.
Turns
Cursor continuity and engagement continuity have independent lifetimes, on purpose.
The conversation carrier persists currentSkillId — the cursor — across turns. It carries no engagement state at all. So on a new turn the kernel starts fresh and re-evaluates engagement from that turn's evidence: a new user message is new evidence, and neither an old parked state nor an old explicit activation gets to silently decide the new turn.
The carried cursor arrives on turn two with no clause explaining why it is there. That is what assumed is for: nobody said why. It is the weakest rung and it is tentative, so it decays and parks like any other guess.
That rung closed a real hole. An absent clause used to be recorded as structural — the strongest, non-decaying category — so turn one's mistaken pattern match was laundered into a permanent warrant on turn two and every turn after. Three routes led into it: a graph with no explainNextSkill hook, so no cause ever arrives; a stay or none, which are not moves; and the turn boundary itself.
Reading the record
advanceEngagement is the whole lease machine as one pure function: give it the EngagementPlan (the mounted maps and the grace window), the current MapEngagement, and this pass's EngagementPass, and it hands back an EngagementAdvance — the next state plus the list of changes.
The state is a MapEngagement, an array of MapEngagementRecord. Each record names its map, its EngagementStanding (engaged or parked), the strength it currently rests on, the iteration that standing began, its idle count, and — when the engagement was a guess — the witness text that founded it.
MountedMap is one map as the kernel sees it: an id, its member ids, and the tool names its members contribute. That is deliberately all the kernel knows. It never sees a cursor, because it has no business moving one.
Each EngagementChange the advance returns becomes a typed event you can observe:
agentfootprint.map.engaged— a map's contributions are riding the calls again, or for the first time.agentfootprint.map.parked— a map's contributions have stopped riding, with the idle count that earned it.
Both carry the strength the standing rests on, so a recording shows you not just what the kernel did but what it thought it knew.
Stating a value honestly
The maps door also exports the honesty primitive the kernel is built on. Claim<T> is a value that says how it knows itself: known(value, evidence), unknown(reason), or notApplicable(evidence).
The point is the third state. Two recorded failures came from a system stating a value it did not hold — a final summary written from a trimmed memory, and a served list whose silent cap read as completeness. A Claim makes "we do not know, and here is why" a value a consumer must branch on: isKnown is the only door to .value, valueOr makes the fallback a required argument so a silent undefined cannot creep back in, and describeClaim renders one plain sentence for a record or a prompt.
import { known, unknown, isKnown, describeClaim } from 'agentfootprint/maps';
const total = known(11, 'the app declared total on the choices block');
const cacheRead = unknown<number>('the provider reported no cache fields');
if (isKnown(total)) console.log(total.value); // 11
console.log(describeClaim(cacheRead)); // "unknown — the provider reported no cache fields"An unknown count can never become a zero, because the type has no door from unknown to .value.
Testing the law yourself
Everything above is plain data and pure functions, exported so you can reason about the lease law directly — no agent, no provider, no model call:
import { advanceEngagement } from 'agentfootprint/maps';
const plan = {
maps: [{ id: 'skill-map', memberIds: ['audit'], toolNames: ['get_zones'] }],
renewalGrace: 3,
};
// iteration 1: an entry pattern placed the cursor — a lexical guess
const { next, changes } = advanceEngagement(plan, undefined, {
iteration: 1,
currentNode: 'audit',
moveBy: 'entry',
witness: 'zone',
toolResults: [],
acceptedSkillPicks: [],
servedLastPass: [],
});Keep calling it with the passes that followed and you can watch the recorded turn park on call four.
Skills, explained
Skills are context engineering for instructions — abstracted so you don't do it by hand and get it wrong. A conceptual walk through what they actually are, and why the abstraction exists.
Tools
defineTool — flat builder for the Tool interface. JSON schema in, async execute out. Drop into agent.tool() or pull a whole MCP server's surface via agent.tools().
