Package Lab: one file to an embedded host
Theo maintains fictional field observations. His first notebook fits in one file. Later he wants reusable helpers, optional package features and an application that embeds OMP.
His obstacle is architectural: an imported helper, an extension entry and an installed plugin are not the same thing.
Package Lab’s complete instructions accompany the source bundle.
Milestone: one file is enough
Files:
Terminal shell:
omp --no-extensions -e "$EXAMPLES/package-lab/single/field-notes.ts"
Human OMP slash command:
/field-notes reed
Model tool arguments—call field_notes:
{"op":"discover"}
{"op":"inspect","value":"reed"}
{"op":"query","value":"marsh"}
Observed discovery details:
{
"operations": ["discover", "inspect", "query"],
"ids": ["reed", "fern"],
"writes": false
}
Inspection returns the fictional reed beside the footbridge.
The tool lowercases and trims its search value. Query searches ID, habitat and text. The slash command is narrower: it lists all notes or matches an exact lowercased ID.
Unknown tool inspection IDs throw. Unknown human IDs produce a warning.
This entry does not declare approval or loadMode. Therefore the current defaults apply: an execution-tier approval declaration and discoverable presentation, despite the domain being read-only. Descriptions do not set approval policy.
The command uses notifications without a headless message fallback. Its machine tool remains useful without the notification.
Exercise: query woodland.
Checkpoint: the tool finds fern. Do not assume /field-notes woodland performs the same full-text query; it is an ID-oriented human command.
Milestone: helpers become ordinary imports
Theo adds a current selection. He wants both /notebook fern and a tool act to change the same selection, without duplicating lookup logic.
Files:
Terminal shell—after exiting the single-file launch:
omp --no-extensions -e "$EXAMPLES/package-lab/multi"
The directory resolves to index.ts. notebook.ts is imported as a helper; it is not independently bound as another extension.
Complete helper source—multi/notebook.ts:
export const notes = [
{ id: "reed", text: "Fictional reeds border the marsh trail." },
{ id: "fern", text: "Fictional ferns shade the woodland path." },
] as const;
export function inspectNote(id: string): (typeof notes)[number] {
const note = notes.find(candidate => candidate.id === id);
if (!note) throw new Error(`Unknown note id: ${id}. Discover ids before selecting.`);
return note;
}
Model tool arguments—call notebook, one at a time:
{"op":"discover"}
{"op":"inspect","id":"reed"}
{"op":"act","id":"reed"}
{"op":"query"}
Human OMP slash commands:
/notebook fern
/notebook
Observed: command and tool shared the factory’s state. An invalid action left the earlier selection intact. Exact completed arguments returned no completion.
The lifetime correction
The selection is declared inside the factory:
Exact excerpt from multi/index.ts:
// Factory-local state belongs to one binding, not Bun's cached module.
let selected: string | null = null;
That prevents this variable from being shared merely because a cached module is rebound. It does not make it transcript-local.
Observed SDK lifecycle checks:
| Operation | Transcript identity | Factory selection |
|---|---|---|
Select reed | Unchanged | reed |
newSession() | Changes | Still reed |
Select fern, then switchSession() | Changes to a real target header | Still fern |
| Construct a separate SDK host | Separate binding | Starts at null |
| Rebind prepared factories | Fresh runtime and extension objects | Starts at null |
The corrected teaching language is factory-local or binding-local, not session-local.
The public helpers deliberately do not reset on session_switch and do not append selection entries.
Exercise: select fern, use /new, then run /notebook.
Checkpoint: within the reused binding, it remains selected. If you want transcript-local selection, add explicit lifecycle reset or branch reconstruction in a new exercise; do not claim the supplied stage already does that.
Milestone: a manifest declares several entries
Theo now wants a catalog and a reusable prompt. An optional summary tool should be available only when selected as a package feature.
Files:
Exact config file—manifest/package.json:
{
"name": "omp-workbook-field-notes",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Fictional field notes with an optional plugin summary",
"omp": {
"extensions": ["./entries/catalog.ts", "./entries/resources.ts"],
"features": {
"summary": {
"description": "Add the fictional habitat summary tool",
"default": false,
"extensions": ["./entries/summary.ts"]
}
}
}
}
Terminal shell—load the base manifest entries:
omp --no-extensions -e "$EXAMPLES/package-lab/manifest"
The base catalog registers field_catalog and /field-catalog.
Model tool arguments—call field_catalog:
{"op":"discover"}
{"op":"query","habitat":"marsh"}
Explicit -e directory loading reads the base extensions list. It does not select plugin features.
To try the optional entry without installation:
Terminal shell:
omp --no-extensions -e "$EXAMPLES/package-lab/manifest" -e "$EXAMPLES/package-lab/manifest/entries/summary.ts"
Model tool arguments—call field_summary:
{}
Expected result details:
{"fictional":true,"habitats":["marsh","woodland"]}
field_summary has no op parameter.
Observed: default installed-plugin feature resolution selected two entries; enabling summary selected three. Explicit base-manifest directory discovery did not include the optional entry.
Resource registration is explained in Resources, event buses, MCP and Gemini manifests. Its runner-level proof does not establish automatic prompt-menu rendering.
Exercise: why not import summary.ts for its side effects from the base catalog?
Answer: that would undermine the feature-selection boundary. A feature is meaningful only if its code is not activated through another unconditional path.
Milestone: embed a factory in an SDK host
Theo’s final goal is to include the notebook in another application without installing a global plugin.
Files:
createFieldNotesExtension(name) returns an ExtensionFactory. The SDK’s extensions option takes functions, not file paths. File paths belong in additionalExtensionPaths.
Exact excerpt—session options in inline/session.ts; use the linked complete module:
const result = await createAgentSession({
cwd: scratchDirectory,
agentDir: path.join(scratchDirectory, "agent"),
authStorage: auth,
modelRegistry: new ModelRegistry(auth, path.join(scratchDirectory, "models.yml")),
settings: Settings.isolated(),
sessionManager: SessionManager.inMemory(scratchDirectory),
disableExtensionDiscovery: true,
extensions: [createFieldNotesExtension("Fictional field notebook")],
enableMCP: false,
enableLsp: false,
skipPythonPreflight: true,
preloadedCustomToolPaths: [],
skills: [], rules: [], contextFiles: [], promptTemplates: [], slashCommands: [],
toolNames: [],
});
The complete module constructs an in-memory auth store, closes auth on failure, and returns a close() function that disposes the session before closing auth.
Install the workbook-compatible SDK package into the environment that runs the script. Package Lab marks it as an optional peer; the example is private and is not presented as a registry-published package.
Terminal shell—with that runtime dependency available:
bun "$EXAMPLES/package-lab/inline/run.ts"
The script prints metadata containing providerPromptSent: false, the inline_notebook tool and the registered command names, then disposes the host. It never calls session.prompt().
That is not a blanket no-network guarantee for arbitrary host startup: model registries and other host configuration can perform discovery. The recorded isolated run had network denied.
Binding is not initialization
createAgentSession() builds the session and binds factories. A mode adapter subsequently initializes the extension runner’s live actions/UI.
The public metadata script does not pretend to be a full TUI/RPC host. Its notebook tool uses closure state, so the proof could exercise it through the real adapter without needing message actions. An embedding that uses appendEntry(), message delivery, dialogs or session actions must wire the real runtime—not replace those methods with successful no-ops.
The inline tool’s discover, inspect and query all return its notebook contract and current selection. It does not implement a separate per-note inspection record. act requires reed or fern.
Prepared factories versus bound instances
Prepared factories can be rebound to a fresh runtime. Already-bound extension instances close over their original API and must not be forwarded to an independently constructed SDK session.
The CLI’s early preload is a special same-owner optimization for flag parsing. It is not a general pattern for sharing a parent’s loaded instances with a child.
Exercise: share a module-scope mutable selection between two SDK hosts. What risk have you introduced?
Answer: the module may be cached, so both factory bindings can reach the same mutable variable. Put binding state inside the factory, and use explicit storage for any state intentionally shared across bindings.
Source, snapshot 2026-08-29: linked Package Lab files; packages/coding-agent/src/sdk.ts, CreateAgentSessionOptions, createAgentSessionScoped; packages/coding-agent/src/extensibility/extensions/loader.ts, loadExtensions, bindPreparedExtensions; recorded loading/rebinding scenarios.
Extensions inside those boundaries · Source chapter: extensions/package-lab-one-file-to-an-embedded-host. Original evidence remains scoped to its recorded snapshot.