An explainer for the owner · p3 resource model · second edition · 2026-11-08

Groupings Show
One List Equips

What a session may use, who decides, and what you will see on screen, with the code behind each piece

1field decides what a session may use
1policy, asked at one guarded step
39p2 plugins checked against it by two reviewers
4decisions left for you

For Mario. The model you agreed on in the car (decision "Resource model direction", 2026-11-08), specified with typed APIs in apps/p3/docs/resource-model/book.md (the spec, third edition) and reviewed by Astra and Sol against every p2 plugin. This explainer shows it: mockups of the screens, the code each plugin writes, and the sequences that run. Mockups are illustrations, not designs. Nothing is built.

Before we begin

You open p3 on the laptop. Your sessions have your models, your MCP servers, your machines and every tool the installed plugins bring; you never configured any of it. At Earendil, Colin, Cristina and Charlotta share a project; its sessions use exactly what the three brought, with each owner's own login. A Discord channel's session may read the answers of one other session for an hour and nothing else. Engineering may create MCP servers; research may not, yet researchers use the one Colin brought into their project.

All of that comes from a few pieces. This book shows each piece three ways: what you would see, the code a plugin writes, and what runs when something changes.

Seven things to know

  1. Groupings show; one list equips. A session appears in any number of groupings (recent, folders, projects, Discord) and uses exactly one list.
  2. That list comes from one field, the session's resource source: your defaults, its own list, or one grouping with resources such as a project or a Discord channel.
  3. Every resource has one owner forever, and with it one login. Users create resources for themselves; admins for the instance user, which owns company resources and everything plugins bring, such as tools.
  4. Your defaults are a rule, not a list: everything you own plus every instance resource you may use. A new plugin's tool appears in your next turn by itself.
  5. One policy decides, asked by core at the moment something happens. Plugins never know about ACL, SAML or Radius; pages never check anything, they show what the server's views say.
  6. The worker follows its list, and checks it at every tool call; server views check it at every call too.
  7. Anything can draw anything: creators per resource kind, renderers per form, detail views assembled from several plugins' panels; layouts are plugins.

Words used throughout

A resource is something a session can use: a model provider, an MCP server, a machine, a drive, a tool, access to another session. Its owner is a user, or the instance user for company resources and for what plugins bring (tools that need no setup). A grouping is a plugin's object that shows things; a grouping with resources also keeps a list. A session's resource source names where its list comes from; the effective list is what its worker gets. The policy is the one plugin that answers typed requests; a creator is a resource kind's "new ..." sheet; a renderer draws a thing in a form (row, card, detail); a panel is one plugin's part of a detail view.

I

Two Questions

The idea the whole model rests on: groupings show things, one list equips a session.

Every model we tried before asked groupings to decide what a session may use. Folders, projects, Discord servers and SAML groups each wanted a say, and each say needed a rule for combining it with the others: include, restrict, precedence, which login wins. Astra and Sol, designing independently, both cut the knot the same way. There are two questions, and they get two answers.

GROUPINGS: SHOWSESSIONLISTS: EQUIPRecentper user, from activityFolderscomputed from locationsProject picurated: members,resources#supporta Discord channelSession S1source: project piSession S2source: #supportpi's listLinear · gpu · Anthropic#support's listcompany key · access to XshowsshowsshowsshowsusesusesDashed: appearing, any number. Solid: the one resource source, one field in the session catalog.
Figure 1. Where something appears (top) and what a session may use (bottom) are separate. A session appears in any number of groupings; one field names its one list.

The second answer is one field in the session catalog, the session's resource source: its owner's defaults, its own list, or one grouping with resources. One field means one source, by construction. Nothing checks across groupings, nothing needs precedence. Moving a session into another project replaces the field.

II

Who Owns What

A resource is a row: a name, an id, an owner, a label. A plugin brings it or a person creates it.

Every person is a user. One more user exists from the start: the instance user. It owns what belongs to the company rather than to a person (the company's Claude key, the build machine, the Discord bot) and everything the installed plugins bring. Nobody signs in as it; admins act for it.

Users

A user is an id the principal carries ({ kind: "user", id }), a profile (name, email, avatar, kept by the kit's users plugin), the identities the person signs in with, and an admin flag. Everything else about a person lives with the plugin that needs it, keyed by the id: recent sessions, exclusions from defaults, model defaults.

Nobody creates users by hand. Every way in is a plugin that verifies something and asks the users plugin who that is:

// plugins/saml/server.ts: verify, map the identity to a user, issue a kit session
const assertion = await verify(await request.body(), idp.value, ctx);
const id = await identities.signIn({ issuer: assertion.issuer, subject: assertion.subject, profile }, ctx);   // created at first sign-in
const session = await sessions.issue({ principal: { kind: "user", id }, label: profile.name, kind: "browser", secure: true }, ctx);
  • Identities, not emails: a user is found by issuer and subject; a second sign-in method is linked explicitly.
  • The first user is an admin (on the laptop: the owner link signs in as that user). Who else administers is the policy's answer, so "members of the SAML group admins" works without touching the users plugin.
  • The instance user never signs in and is never in the user list; pickers show it as "the company".
  • When someone leaves, an admin disables them: their sign-in sessions end, and their resources drop out of every list at once, because their logins are theirs. Their sessions stay for the people who could see them.

Resources

A resource is anything a session can use: a tool, a model provider, an MCP server, a machine, a drive, access to one other session. The model needs four facts about it, so a resource is one row in the resources plugin's table:

name id owner label
coding-tools.bash instance Bash
mcp.server 7f3a Colin Linear
conversations.access 91c2 Mario Release notes draft · answers

There is no resource object. When a resource has state (an MCP server's configuration, its sign-in, its tools), that state belongs to the plugin that made it, keyed by the id. The name comes from one function, written once in the plugin's contract file:

// plugins/coding-tools/contract.ts
export const Bash = defineResource("coding-tools.bash");

// plugins/mcp/contract.ts
export const McpServer = defineResource("mcp.server");

A resource comes to exist in one of two ways:

  • A plugin brings it. coding-tools contributes [Bash] to a server registry, InstanceResources. While the plugin is installed, the resource exists, owned by the instance user; uninstalled, it is gone from every list. No call, no row, no actor: installing a plugin is the admin's act.
  • A person creates it. Colin's MCP sheet calls the MCP plugin, which calls Resources.create with Colin as actor and owner: the owner rule ("you create only for yourself; admins also for the company"), the policy, then the row. The MCP plugin stores the configuration under the id it got back. Colin's server is Colin's forever, with Colin's login; Cristina wanting her own Linear login means a second resource.
// plugins/coding-tools/server.ts
self.contribute(InstanceResources, [Bash]);

// plugins/mcp/server.ts, inside McpServers.create (the method behind the creation sheet)
const resource = await resources.create({ actor: principal.id, owner, definition: McpServer, label: config.name }, ctx);
await store.add({ id: resource.id, owner, config }, ctx);
Is a project a resource?

No. A resource is something a session uses. A project is a grouping that keeps a list of resources; a session in pi does not use pi, it uses what is on pi's list. In code, the session's source field points to the project; the project is never an entry on any list.

III

The One List

Defaults, an own list, or a grouping's list: what a session uses, and what you see of it.

A session's resource source is one of three:

export const ResourceSource = union([
	obj({ kind: lit("defaults") }),                          // a rule: everything I own + every instance resource I may use
	obj({ kind: lit("own") }),                               // the session's own list
	obj({ kind: lit("grouping"), grouping: ObjectAddress }), // one grouping with resources: a project, a Discord channel
]);

Defaults is what Mario lives in and never sees. It is not a list anyone keeps; it is the rule everything I own, plus every instance resource I may use. Install a plugin with a new tool and the rule includes it: the tool is in the next turn of every normal session. Someone who wants code mode off everywhere adds it to their exclusions (a proposal, decision 3 at the end). Own is what "Lock down this session" makes: a snapshot of the current list the user then trims; from a project, only what the user may bring themselves is kept, so Cristina cannot walk off with Colin's server. Grouping is a project or a Discord channel; its plugin keeps the list.

Everyone who may send in a session uses its whole list. That is the owner's decision of 2026-11-08: prompt injection cannot be prevented, so what is brought into a session is the control.

The default layout shows a session's list in its side panel, with where it comes from at the top:

p3 · pi / Fix flaky upload test
Recent recent
SFix flaky upload test2m
SRelease notes draft1h
Folders folders
Mgpu:~/pi2
Mthis machine:~/notes5
Projects projects
▾ pi3 people
SFix flaky upload test
SGPU benchmark
▸ research-notes
Discord discord
▾ Earendil
# supportSupport bot
engineering saml
CColin, Ada, +10
Fix flaky upload test
gpu:~/pi · project pi · owner Cristina
CharlottaWhy does the upload test flake on gpu? There is a Linear issue about it.
linear.search "upload flaky" → PI-412
bash npm test -- upload --repeat 20
PiPI-412 matches: the test races the temp-dir cleanup. 3 of 20 runs fail; a fix is ready in upload.test.ts.
Resources core
Pfrom project pi
L
Linear
MCP server · Colin
G
gpu
machine · Cristina
A
Anthropic
model provider · Charlotta
T
Built-in tools · 14
instance
instance
Add resource Change source
Each row is drawn by its kind's plugin (its Row renderer); the panel only lists addresses.
Figure 2. The default layout: groupings in the sidebar (each section from its own plugin), session S1 in the middle, its list on the right. S1's source is project pi; Charlotta's question uses Colin's Linear login, Cristina's machine and Charlotta's key.
IV

Groupings

Computed and curated groupings, the few that carry resources, and a project from the inside.

A grouping is any plugin's object that shows other objects. Folders are computed from where sessions work: nobody puts a session there. Recent is per user, from what each user opened. Projects are curated by people. A mirrored SAML group shows its members. None of these needs core, except the groupings that carry resources: they keep their list in their own plugin and register with core how to read and change it.

// plugins/projects/server.ts: the projects plugin knows nothing about MCP, models or ACL
self.contribute(Groupings, [{
	grouping: Project,                               // defineGrouping("projects.project")
	members,                                         // users and groupings in each project
	resources: {
		lists,                                       // each project's entries, kept by this plugin
		mayBring: isMember,                          // the list side of bringing: the project's own rule
		mayRemove: isMaintainer,
		mayJoin: isMember,                           // making a session's source this project
		add: (id, entry, ctx) => lists.change(id, (draft) => { draft.push(entry); }, ctx),
		remove: (id, resource, ctx) => lists.change(id, (draft) => removeEntry(draft, resource), ctx),
	},
}]);

A project's sessions are a query: catalog rows whose source is the project. The session points up, never the project down, so two projects can never both equip one session. Selecting the project shows the project plugin's detail view, whose rows are drawn by each thing's own plugin:

p3 · pi
pi
projectsNew session here
Project · 3 people and 1 group · 5 resources · 2 sessions · you are a maintainer
People users
C
Colin
maintainer
C
Cristina
member
C
Charlotta
member
E
engineering
SAML group · 12
saml
Add person or group
Resources core list
L
Linear
brought by Colin · 23 tools · signed in
mcp
G
gpu
brought by Cristina · ready · 2× A100
envs
A
Anthropic
brought by Charlotta · 9 models
models
D
Company drive
instance · until Fri
until Fri
T
Built-in tools · 14
bash, files, subagents… (decision 1)
instance
Add resource
Sessions sessions
S
Fix flaky upload test
Cristina · working
S
GPU benchmark
Colin · idle 3h
Figure 3. Project pi selected. The projects plugin lists addresses in three columns; every row is drawn by the renderer of that thing's plugin (tags show which), so the project plugin never learns what an MCP server is.

A Discord channel linked to a session is a grouping with resources too: its list is what the channel may use, and the link sheet edits it.

V

Adding Things

Creators per kind, a picker filtered by what the server says you may create, and bringing in one step.

Each resource kind contributes a creator to a page registry: a name, an icon, a description and the sheet that creates one. Any "Add resource" button opens the same picker: your existing resources and the instance's on top, the creators below, filtered by one list the server computes for you, creatable. Install a plugin and its creator appears; the picker knows no kinds in advance and no rules at all.

// plugins/mcp/web.tsx
self.contribute(Creators, [{ definition: McpServer, label: t.message("mcpServer"), description: t.message("mcpAbout"), icon: PlugIcon, Sheet: NewMcpSheet }]);

// plugins/resources/web.tsx: the picker behind every "Add resource"
const creatable = useWatch(useHold(MyResources, {}).creatable) ?? [];          // computed on the server, per owner
const allowed = creatable.find((c) => c.owner === owner)?.definitions ?? [];
const creators = useRegistry(Creators).filter((c) => allowed.includes(c.definition.name));
Add a resource to project pi
CColin sees
Yours
R
Radius MCP
MCP server · yours
Bring
L
Linear
already in pi
Create new
M
MCP serverHTTP, or a checkout's suggestion
P
Model providerAPI key or sign-in
M
MachineSSH host or sandbox
D
Drivefiles people and agents share
CCristina sees
Yours
G
gpu
already in pi
D
Cristina's drive
drive · yours
Bring
Create new
P
Model providerAPI key or sign-in
M
MachineSSH host or sandbox
D
Drivefiles people and agents share
–
No MCP servernot in your creatable kinds
Figure 4. The same picker for Colin (engineering) and Cristina (research), with the rule "only engineering may create MCP servers". Cristina's view lacks the MCP creator because her creatable lacks it; she still uses Colin's server in pi.

Creating from a list creates and brings in one step for the user: the creator's sheet calls its plugin's create (core's guarded step checks the owner rule and the policy), then the picker brings the new resource into the list with the duration the user picked. Bringing needs two yeses: the resource's side (you own it; instance resources: you administer) and the list's side (the project lets you add). Admins get one more choice in the picker, the owner: themselves or the company, which is how the company's Claude key or build machine is created although nobody signs in as the instance user.

VI

Who May

One replaceable policy, typed requests, core's guarded steps, and a rule change that reaches Cristina's page by itself.

The groupings from SAML, Radius and projects do not know each other. One policy decides, behind one registry, asked by core at the moment an action happens, on the server. Requests are typed values, kinds are tokens compared by identity:

export type Request =
	| { readonly kind: "create-resource"; readonly actor: UserId; readonly definition: ResourceDefinition; readonly owner: UserId }
	| { readonly kind: "create-grouping"; readonly actor: UserId; readonly grouping: GroupingDefinition }
	| { readonly kind: "bring"; readonly actor: UserId; readonly resource: ResourceRef; readonly into: ListRef; readonly until: number | null }
	| { readonly kind: "use"; readonly session: SessionRow; readonly resource: ResourceRef }
	| { readonly kind: "administer"; readonly actor: UserId }
	| /* set-source, add-member, and a plugin's own operation(Contract, "method") */;

export type Policy = { decide(request: Request, baseline: Decision): Decision };
export const Policies = defineRegistry<Policy, Policy>("p3.policy", { version: 1 });

The default policy returns the baseline: the answer core and the owning plugin give from their own facts. On a laptop that is "Mario may do everything". A company installs the rules plugin, and an admin edits rules on a settings screen built from the same creators registry and every plugin's groupings:

Settings · Access rules
Access rules
rulesPolicy in use: rules · admins only
PermissionOnly members of
Create · MMCP serverEengineeringRresearchjust added
Create · PModel providerAadmins
Use · MThis machineAadmins
AdministerAadmins
+ RuleKinds: MCP server · Model provider · Machine · Drive · Access to a session · Weather tool creators
Figure 5. The rules plugin's settings screen. Kinds come from the Creators registry (names, icons), groups from any plugin's groupings (here SAML). A permission with rules is restricted to their members; one without rules keeps the baseline.

Adding research to the first rule reaches Cristina's open picker without a reload, without polling, and without the picker knowing anything about groups:

admin's pagerules screenrules pluginthe policycore resourcesserverMyResourcesCristina's viewCristina's pagepickeraddRule { research may create mcp.server }commit; policy.set(new value)Policies value changedderive creatable per live viewcreatable gains mcp.serverstate pushed"MCP server" appears
Figure 6. An admin lets research create MCP servers; Cristina's picker gains the MCP creator.
VII

Discord, Locked Down

A configured resource: access to one session, chosen actions, one hour, added from the Discord session itself.

The owner's case: the Discord session may read the answers of one other session, only that one, for an hour, then lose it again. The conversations tool has actions; the resource is the permission, not the tool:

// plugins/conversations/contract.ts
export const ConversationsTool = defineResource("conversations.tool");      // brought: the tool itself
export const ConversationAccess = defineResource("conversations.access");   // created: access to one session
export const AccessConfig = obj({ target: str(), actions: arr(oneOf("answer", "send")) });

// plugins/conversations/server.ts
self.contribute(InstanceResources, [ConversationsTool]);
// SessionConversations (the worker's view): targets from the list's access entries; every send/answer finds the
// entry naming that target and action, checks its deadline now, and that its owner may still send there.

// plugins/conversations/worker.ts: the extension, keyed by the tool resource
self.contribute(Extensions, [[ConversationsTool, conversationsExtension(session)]]);

The channel's list therefore holds three things: the company key, the conversations tool, and the access. The add sheet puts the tool on the list together with the first access if it is missing.

Mario starts where he wants, on the Discord session: Add resource, "Access to a session", picks the target, answer only, one hour. Two checks run: may Mario grant access to that target (he can send in it), may he change the channel's list (the Discord plugin's rule). The expiry sits on the list entry, not on the resource.

p3 · Earendil / #support / Support bot
Support bot
# support · Discord · owner Mario
Lena · DiscordIs 0.9 out yet? The changelog link 404s.
conversations.answer → "Release notes draft"
PiNot yet: the release notes are still in draft; the 0.9 changelog goes up with the release.
Tom · DiscordCan you tell the release session to hurry up?
conversations.send → refused: this session may only read answers of "Release notes draft"
Resources core
#from #support discord
C
Company Claude key
model provider · instance
→
Release notes draft
answers only · Mario · ends 14:32
Add resource
No bash, no machine, no MCP: nothing else is on the channel's list.
Figure 7. Session S2, fed by #support. Its source is the channel's list: the company key and access to one session, answers only, ending at 14:32. Discord people drive the whole list; the list is the control.

At 14:32 the entry leaves the effective list; a call a second later is refused by the server, which finds the exact entry behind that target and action and checks its deadline against the current time, whatever the worker still shows. The session has no machine at all: its location is empty, so there is nothing to run bash on and no fallback to the server machine.

VIII

On the Worker

The worker follows its list; the harness lets in only extensions whose resource is on it; server views check every call.

The list is computed on the server and pushed to the session's worker as the state of one view, SessionResources. The worker reads it locally; a change arrives as one push. Nobody polls.

A plugin's worker facet contributes its Durable extension together with the resource it belongs to, the same constant its server facet declared:

// plugins/coding-tools/worker.ts: each extension keyed by the resource it needs
self.contribute(Extensions, [
	[FileTools, defineExtension({ name: "files", tools: [read, write, edit] })],
	[Bash, defineExtension({ name: "bash", tools: [bash] })],
]);

// plugins/harness/worker.ts: the one check
const allowed = (resource: ResourceRef): boolean =>
	resources.state.value.kind === "live" && list.kind === "ready" &&
	list.entries.some((e) => sameResource(e.resource, resource) && (e.until === null || e.until > Date.now()));

Tools stop guarding themselves: p2's origin and locked fields go; scope (main conversation or subagents) stays.

The filter alone is not enough, the review found: a code-mode script captured its tools when it started, and a worker that lost its link still holds its last list. So the harness also checks at every tool call, nested ones included: link live, list ready, entry present, deadline not passed. Things that are not tools check at their own door: the composer's !cmd needs Bash on the list, a cache warmer re-admits its provider before each refresh, terminal and voice channels re-check on use.

S1's workerharnessSessionResourcesserver, coreSessionModelsserver, modelsSessionMcpserver, mcphold (principal: worker S1)list ready: Linear, gpu, Anthropic, toolsallowed(): files, bash, conversations, LinearholdAnthropic + auth (expires 15:10)holdLinear: 23 toolsresume Durable: first turn runseach server view reads the same effective list: resourcesOf(self, principal, Kind)
Figure 8. A session worker starts and follows its list: the harness admits extensions, models get pushed auth, MCP tools appear.

Resource plugins' per-session server views read the same list and check it at every call, so the worker is never the last line:

Colin's pageMyResourcescore resourcesserverprojectspi's listSessionMcpS1's viewS1's workerharnesswithdraw Linearremove(pi, Linear)pi's list changedrecompute S1's listlist without LinearLinear gone(sent before) call linear.searchrefused: not on S1's listnext phase: no Linear tools
Figure 9. Colin withdraws Linear from project pi while S1 is mid-script; the next call is refused on the server.

A call admitted before Colin's commit may finish; nothing is replayed. What is already in the transcript stays.

IX

Kinds in Code

How models, MCP, machines and drives look under the same three parts.

Every kind has the same three parts: a token in its contract file, a server facet that registers it and serves a per-session view, a worker facet that contributes its extensions keyed by resource. What differs is what the worker gets.

Model providers push credentials. The worker streams from the provider itself; the server refreshes and pushes pi-ai's auth with its expiry. Refresh tokens never reach the worker, and a cached token alone is not permission: the worker refuses once the provider left the list or the auth expired.

host.serve(SessionModels, (_params, principal, self) => {
	const list = self.get(SessionList, { session: principal.session }).list;   // entries isOf(ModelProvider)
	const store = self.get(ModelStore, {});                       // pi-ai CredentialStore, one refresh per credential
	// a routine pushes { resource, models, auth, expires } for each, again before the earliest expiry
});

MCP servers run on the server. One resource per server, its transport in its configuration: HTTP (no location), server-stdio (the p3 machine; admin by baseline), or checkout (an enabled .pi/mcp.json entry, bound to that env, directory and the entry's digest, run there). The worker gets one extension per server on the list; every tool calls SessionMcp.call with its conversation, so the server admits against the list now and checks that a checkout server runs only where that conversation works. Processes stay per session by default, as in p2 (decision 4).

Machines are where tools run. A conversation's location names an env on the list; the machine p3 runs on is an instance resource. Selecting a machine shows its detail view, assembled from panels three plugins contribute, none of which imports the others:

self.contribute(ResourcePanels, [{ definition: Machine, title: t.message("files"), order: 10, Panel: MachineFiles }]);          // files
self.contribute(ResourcePanels, [{ definition: Machine, title: t.message("terminals"), order: 20, Panel: MachineTerminals }]);  // terminals
self.contribute(ResourcePanels, [{ definition: Machine, title: t.message("settings"), order: 90, Panel: MachineSettings }]);    // envs
p3 · gpu
G
gpu
readyCristina · ssh cristina@gpu.lab · Linux · 2× A100row & header: envs
Files files
Terminals terminals
Settings envs
VNC vnc (3rd party)
~/pi · bash~/bench · nvidia-smi+
cristina@gpu:~/pi$ npm test -- upload --repeat 20
✓ upload › resumes after drop (19/20)
✗ upload › cleans temp dir (1/20) ENOENT: /tmp/pi-up-7f3a
cristina@gpu:~/pi$ ▍
Figure 10. Machine gpu selected. The detail sheet mounts every panel registered for Machine, each from its own plugin; a fourth (VNC) is someone's plugin added later. Each panel checks its own operations on the server.

Drives are file spaces. People use a drive directly (the owner manages, members of a list holding it read); an agent gets drive tools while any drive is on its list, each call naming a drive the server admits.

X

Sign-in and Directories

SAML and Radius touch three places, each already in the model.

Nothing about SAML or Radius appears in projects, MCP, models or the harness.

  1. Sign-in creates users. A SAML plugin verifies the assertion and calls Users.signIn({ issuer, subject, name }). Emails are never identities.
  2. Sync mirrors groups as groupings. SamlGroup { name } registers its members with core; they appear in the sidebar like any grouping and can be members of projects. A Radius plugin mirrors Radius org groups the same way.
  3. The policy names them. "Only engineering may create MCP servers" is one rule on the settings screen above.

Radius resources are ordinary resources: signing in with Radius can create the user's Radius MCP server and Radius model provider, by calling those plugins' create as that user. Whether one Radius sign-in can serve the relay, the model gateway and the MCP server is the question still open with Armin and Vegard; in p2 these are three logins.

XI

Resources on Screen

Creation sheets, rows and detail panels come from the plugin that owns a resource; the session panel, the picker and My resources are core's.

The page never decides. It draws server views and calls their methods, and the server checks. A plugin adds UI for its resources through three page registries, all keyed by its resource definition:

// plugins/mcp/web.tsx
self.contribute(Creators, [{ definition: McpServer, label: t.message("mcpServer"), description: t.message("mcpAbout"), icon: PlugIcon, Sheet: NewMcpSheet }]);
self.contribute(ResourceRows, [{ definition: McpServer, Row: McpRow }]);
self.contribute(ResourcePanels, [
	{ definition: McpServer, title: t.message("tools"), order: 10, Panel: McpTools },
	{ definition: McpServer, title: t.message("connection"), order: 20, Panel: McpConnection },
	{ definition: McpServer, title: t.message("configuration"), order: 90, Panel: McpConfiguration },
]);

Core's resources plugin draws everything around them: the session's resource panel, the add-resource picker, the detail sheet's header (owner, where it is used, withdraw), and My resources. Each reads a server view of its own: SessionResourcesView { session }, MyResources, ResourceInfo { name, id }. Their state includes the actions this user may take now, computed with the same rules the methods check, so a button shown is a button that works.

Creating

The sheet is the MCP plugin's own form; submitting calls its server method, which runs the guarded step:

// plugins/mcp/web.tsx
function NewMcpSheet({ owner, done, cancel }: { owner: UserId; done(resource: ResourceRef): void; cancel(): void }) {
	const servers = useHold(McpServers, {});
	const [config, setConfig] = usePageState<McpConfig>("mcp.new", emptyConfig);
	return <Form title={t("newMcpServer")} onCancel={cancel}
		onSubmit={async (ctx) => done(await servers.create({ owner, config }, ctx))}>   {/* a refusal shows its reason */}
		<TextField label={t("name")} value={config.name} onChange={(name) => setConfig({ ...config, name })} />
		<TransportField value={config.transport} onChange={(transport) => setConfig({ ...config, transport })} />
	</Form>;
}
Add a resource to project pi › New MCP server
M
New MCP server
sheet: mcp
Signed in as you. Everyone who uses pi's sessions will use it with your login.
Name
Connects to
HTTPCommand on this machine · admins
Processes
One per sessionShared
Add to pi picker: core
Owner
you (Colin)
admins: or the company
For how long
until removed
1 houruntil Fri
Create and add to piCancel
Create: McpServers.create → the owner rule and the policy ("only engineering creates MCP servers") → a row, Colin's. Then MyResources.bring into pi: Colin owns it, pi lets members add.
Figure 11. The MCP plugin's creation sheet, opened from "Add resource" on project pi. An admin also gets the owner choice (themselves or the company); Colin sees only his own. The duration belongs to the picker, which brings the new server into pi when the sheet is done.
Colin's pagesheet + pickerMcpServersserver, mcpResourcesserver, coreprojectspi's listpi sessionsworkerscreate { owner: colin, config }create { actor: colin, owner: colin, definition: McpServer }{ name: mcp.server, id: 7f3a }store.add(7f3a, config)the new resourceMyResources.bring { 7f3a, into: pi }mayBring(colin, pi)? add(entry)pi's list changedlists recomputed: Linear added
Figure 12. Creating from a project's picker: the plugin's sheet, the guarded step, the bring, and the sessions that follow.

A resource's detail sheet

Core draws the header from ResourceInfo; every panel registered for the definition follows, from any plugin.

p3 · Linear
L
Linear
signed inheader: core
MCP server · owner Colin · signed in as colin@earendil.com
Used inPpiSTriage bot · until FriCyour sessions (defaults)Exclude from my defaultsWithdraw everywhere
Tools mcp
Connection mcp
Configuration mcp
Usage team-usage
Tool
Calls
Last
search_issues
212
2 min ago
get_issue
97
2 min ago
create_comment
14
yesterday
20 more
Figure 13. Linear's detail sheet as Colin sees it. The header is core's (owner, where it is used, withdraw); the tabs are the MCP plugin's panels, plus a usage panel from a team's own plugin. Cristina sees the same sheet without Configuration and without Withdraw.

My resources

p3 · My resources
Yours core
L
Linear
MCP server · 23 tools · in pi, Triage bot
mcp
A
Anthropic
model provider · 9 models
models
L
laptop
machine · asleep
envs
→
Release notes draft · answers
access to a session · in #support until 14:32
conversations
The company's admins
C
Company Claude key
model provider
B
build machine
machine
T
Tools from plugins · 14
bash, files, conversations, subagents…
excluded: code mode
Create creators
M
MCP serverHTTP, or a command on this machine
P
Model providerAPI key or sign-in
M
MachineSSH host or sandbox
→
Access to a sessionlet another session read or message one of yours
Owner for new ones: you · the company (admins)
Figure 14. My resources, an admin's view: what I own, what the company owns (only admins create those), and the creators I may use. Every row is its plugin's renderer; nothing on the page knows MCP, models or machines.
XII

What the Review Found

Astra and Sol checked every p2 plugin; the model held, the edges needed work.

Both reviewers mapped all 39 p2 plugins onto the spec and traced the six scenarios. Neither asked for a new noun, a second resource source, per-run authority or login modes. Every plugin fits as resources it brings or people create, a grouping, a presentation, or plain session infrastructure (search indexing, status, drafts, tree, file mirrors keep running whatever the list holds). What they found were edges, now fixed in the spec:

  • Taking things away must stick. Withdrawing a resource now raises a revision in core's index first, so the old entry stops counting everywhere at once, even in a project whose plugin is reloading and comes back with its old rows.
  • Copies must not smuggle. Locking a project session down keeps only what the user could bring themselves; clones and forks re-check every entry. Cristina cannot keep Colin's server by detaching her session.
  • Using is not lending. Being allowed to use the company key does not mean being allowed to put it into a project for everyone there.
  • Check at every call. Code-mode scripts capture tools, and a disconnected worker keeps its last list, so the harness checks each tool call, and doors that are not tools (!cmd, cache warmers, terminal and voice channels) check at their own entry.
  • Typed tokens compare by declaration. Each plugin bundles its own copy of another plugin's contract, so === on McpKind would fail; core compares declared names and versions through helpers.
  • Who acted travels as data on local calls, because a local call acts as the server.
  • A session may have no machine. The Discord session that only reads answers has an empty location, and nothing falls back to the server machine.
  • Per-action deadlines. An hour of "answer" and five minutes of "send" to the same session expire separately, and revoking the target's sharing ends the access.
∴

Four Decisions

What only you can decide, each with a recommendation.

  1. Tools in project lists. With "only admins bring instance resources", a project session has no bash unless an admin brings it. Recommendation: what plugins bring carries no login, so anyone who may use it may bring it, and a new project's list starts with all of it that its creator may use. The company key and build machine stay admin-only.
  2. Projects showing sessions they do not equip. Linking a project session to Discord makes it use the channel's list, and it disappears from the project. Recommendation: let a project keep plain "shown here" references, drawn as "uses #support's resources", granting nothing.
  3. Exclusions from your defaults. Recommendation: yes, a per-user set, so you can turn code mode or cache warming off everywhere while new tools still appear.
  4. MCP processes per session. Recommendation: keep p2's default, one process per session, with a typed lifetime setting for servers that should be shared.

Still open elsewhere: whether one Radius sign-in can serve the relay, the models and the MCP server (Armin and Vegard); running code on the server machine safely when several people share it (its own track).

A

Where It Lives

Piece Plugin You meet it as
users, the instance user users sign-in; the company as an owner in pickers
defineResource, the index, lists, sources, MyResources, creators, rows, panels resources (core) the session's resource panel, "Add resource", lock down
groupings with resources projects, discord, ... project view, channel link sheet
policy, typed requests, guard policy (core), rules, default-policy the access rules screen
Extensions keyed by resource, admission per call harness (core) tools appearing and disappearing
creators, renderers, panels, navigation resources, presentation pickers, rows, detail tabs, sidebar sections

The spec with every API: apps/p3/docs/resource-model/book.md. The reviews: /tmp/resource-model-review-astra.md, /tmp/resource-model-review-sol.md.

p3 resource model · explainer, first edition · 2026-11-08 · from the spec and the Astra and Sol reviews