| name | adding-slicc-features |
| description | Use when adding a SLICC shell command, dedicated tool, provider, scoop capability, UI panel, interactive approval, or runtime skill wiring; when `command not found`, a tool/skill is undiscovered, a follower action is dropped, OAuth reports `session expired` or `401 invalid x-api-key`, or `playwright-cli-sync` reports a command gap. Covers exact file paths, code interfaces, registration patterns, and the cross-reference checklist (test, SKILL.md update, follower handler, AGENTS.md).
|
adding-slicc-features
Use this procedure to extend SLICC. Prefer shell commands over dedicated tools, preserve CLI and extension parity, and update every agent-facing reference that exposes changed behavior.
For non-procedural system and protocol background, see docs/architecture.md, docs/shell-reference.md, and docs/tools-reference.md.
Quick Reference
npm run typecheck
npm test
npm run lint
node packages/dev-tools/tools/playwright-cli-sync.mjs
npm run build -w @slicc/chrome-extension
Use these common extension points:
| Capability | Primary path |
|---|
| Supplemental shell command | packages/webapp/src/shell/supplemental-commands/ |
Executable .jsh skill command | packages/vfs-root/workspace/skills/<skill>/ |
| Core agent tool | packages/webapp/src/tools/ and packages/webapp/src/scoops/scoop-context.ts |
| Scoop-management tool | packages/webapp/src/scoops/scoop-management-tools.ts |
| Provider | packages/webapp/src/providers/built-in/ or packages/webapp/providers/ |
| Runtime agent skill | packages/vfs-root/workspace/skills/<skill>/SKILL.md |
1. Add a Supplemental Shell Command
When: To register a new bash command (e.g., convert, webhook, crontask).
Files to modify:
- Create:
packages/webapp/src/shell/supplemental-commands/my-command.ts
- Modify:
packages/webapp/src/shell/supplemental-commands/index.ts
Implementation:
Define a command using just-bash's defineCommand:
import { defineCommand } from 'just-bash';
import type { Command, CommandContext } from 'just-bash';
export function createMyCommand(): Command {
return defineCommand('mycommand', async (args, ctx) => {
try {
const result = await ctx.fs.readFile('/some/path');
return {
stdout: result,
stderr: '',
exitCode: 0,
};
} catch (err) {
return {
stdout: '',
stderr: `Error: ${err}`,
exitCode: 1,
};
}
});
}
Register in createSupplementalCommands():
import { createMyCommand } from './my-command.js';
export function createSupplementalCommands(options: SupplementalCommandsConfig = {}): Command[] {
return [
createMyCommand(),
];
}
Type signature (just-bash):
type Command = {
name: string;
execute: (args: string[], ctx: CommandContext) => Promise<ShellResult>;
};
type CommandContext = {
fs: IFileSystem;
env: Map<string, string>;
cwd: string;
getRegisteredCommands?: () => string[];
};
type ShellResult = {
stdout: string;
stderr: string;
exitCode: number;
};
Test pattern:
import { describe, it, expect, beforeEach } from 'vitest';
import { createMyCommand } from './my-command.js';
import { FakeVirtualFS } from '../../fs/fake-virtual-fs.js';
describe('my-command', () => {
let fs: FakeVirtualFS;
beforeEach(() => {
fs = new FakeVirtualFS();
});
it('should execute correctly', async () => {
const cmd = createMyCommand();
const result = await cmd.execute(['arg1'], {
fs,
env: new Map([['HOME', '/home/user']]),
cwd: '/',
});
expect(result.exitCode).toBe(0);
});
});
Reference file: packages/webapp/src/shell/supplemental-commands/which-command.ts
2. Add a .jsh Script Command
When: To ship executable scripts as part of a skill (e.g., a custom build tool, data processor).
Files to create:
- Create:
packages/vfs-root/workspace/skills/my-skill/my-script.jsh
Implementation:
const fs = require('fs');
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: my-script <input>');
process.exit(1);
}
const inputFile = args[0];
(async () => {
try {
const content = await fs.readFile(inputFile);
const processed = content.toUpperCase();
const outputFile = inputFile.replace(/\.txt$/, '.out.txt');
await fs.writeFile(outputFile, processed);
console.log(`Processed: ${inputFile} → ${outputFile}`);
} catch (err) {
console.error();
process.();
}
})();
Globals API:
| Global / module | Methods |
|---|
process | argv[], env (object), cwd(), exit(code), stdout.write(), stderr.write() |
console | log(), info(), warn(), error() |
require('fs') / require('node:fs') | readFile(path), readFileBinary(path), writeFile(path, content), writeFileBinary(path, bytes), readDir(path), mkdir(path), rm(path), stat(path), exists(path), fetchToFile(url, path) |
require('sliccy:exec') | Callable exec(cmd) + .spawn(argv[]). Shell command bridge. |
require('sliccy:<name>') | http, browser, skill, cli, color, time, fmt, pool, usb / serial / hid — see packages/vfs-root/workspace/skills/skill-authoring/jsh-runtime-extensions.md. |
require(id) | Synchronous CJS require (require('sliccy:<name>'), require('fs'), or installed packages). |
module, exports | Available for CJS module pattern (e.g., a .jsh consumed by require('./helper.jsh')). |
Discovery:
The shell auto-discovers *.jsh files from /workspace/skills/ (priority) and anywhere on the VFS. Call by basename:
my-script arg1 arg2
Execution modes:
- CLI mode: Uses
AsyncFunction constructor, full Node.js-like globals
- Extension mode: Routes through sandbox iframe (CSP-compliant), via postMessage for VFS operations
Test pattern:
JSH scripts cannot be unit-tested in Node because they rely on extension mode detection. Test the logic separately:
import { describe, it, expect } from 'vitest';
import { executeJshFile } from '../jsh-executor.js';
import { FakeVirtualFS } from '../../fs/fake-virtual-fs.js';
describe('my-script.jsh', () => {
it('should run the script', async () => {
const fs = new FakeVirtualFS();
await fs.writeFile('/test.jsh', 'console.log("hello");');
const result = await executeJshFile('/test.jsh', [], {
fs,
env: new Map(),
cwd: '/',
});
expect(result.stdout).toContain('hello');
expect(result.exitCode).toBe(0);
});
});
Reference file: packages/webapp/src/shell/jsh-executor.ts, packages/webapp/src/shell/supplemental-commands/node-command.ts
3. Add a Core Agent Tool
When: To add a tool available to the agent (e.g., a new read_database tool).
Files to create/modify:
- Create:
packages/webapp/src/tools/my-tool.ts
- Modify:
packages/webapp/src/scoops/scoop-context.ts (wiring)
Implementation:
import type { ToolDefinition, ToolResult } from '../core/types.js';
import { createLogger } from '../core/logger.js';
const log = createLogger('tool:my');
export function createMyTool(dependency: SomeDependency): ToolDefinition {
return {
name: 'my_tool',
description: 'Does something useful. Parameters: x (required), y (optional).',
inputSchema: {
type: 'object',
properties: {
x: {
type: 'string',
description: 'The first parameter',
},
y: {
type: 'number',
description: 'Optional second parameter',
},
},
required: ['x'],
},
async execute(input: Record<string, unknown>): Promise<ToolResult> {
const x = input[] ;
y = input[] | ;
log.(, { x, y });
{
result = (x, y, dependency);
{
: ,
: ,
};
} (err) {
message = err ? err. : (err);
log.(, { x, : message });
{
: ,
: ,
};
}
},
};
}
Interface:
interface ToolDefinition {
name: string;
description: string;
inputSchema: ToolInputSchema;
execute(input: Record<string, unknown>): Promise<ToolResult>;
}
interface ToolInputSchema {
type: 'object';
properties?: Record<string, unknown>;
required?: string[];
[k: string]: unknown;
}
interface ToolResult {
content: string;
isError?: boolean;
}
Wire into ScoopContext:
const legacyTools = [
createMyTool(dependency),
];
Test pattern:
import { describe, it, expect } from 'vitest';
import { createMyTool } from './my-tool.js';
describe('my_tool', () => {
it('should execute with valid input', async () => {
const tool = createMyTool(mockDependency);
const result = await tool.execute({ x: 'test' });
expect(result.content).toContain('Result');
expect(result.isError).toBeFalsy();
});
});
Reference file: packages/webapp/src/tools/bash-tool.ts, packages/webapp/src/tools/file-tools.ts
4. Extend Browser Automation Shell Commands
When: To add or change browser automation behavior, tab workflows, or preview-serving commands.
Files to modify:
- Add a handler under
packages/webapp/src/shell/supplemental-commands/playwright/handlers/ (one module per subcommand family) and register it in playwright/handlers/index.ts.
- Shared helpers live in
playwright/ (state.ts, snapshot.ts, session-log.ts, teleport.ts, teleport-storage.ts, discover.ts, help.ts); playwright-command.ts is just the thin dispatcher + public re-exports.
- Modify:
packages/webapp/src/shell/supplemental-commands/serve-command.ts
- Modify:
packages/webapp/src/shell/supplemental-commands/shared.ts (shared preview/path helpers)
- Update:
packages/vfs-root/workspace/skills/playwright-cli/SKILL.md — this file is injected into the agent's system prompt. Every new or changed command MUST be reflected here or the agent will not know about it.
Implementation:
- Keep browser automation shell-first through
playwright-cli / playwright / puppeteer.
- A handler is a
PlaywrightHandler — (ctx: { browser, fs, state, positional, flags }) => Promise<CmdResult>; add the subcommand name (and any alias) to the playwrightHandlers map.
- Reuse shared preview helpers for VFS URLs instead of manually constructing
/preview/... paths.
- Use
serve <dir> for app directories (default index.html, optional --entry) and open for single files, URLs, downloads, or inline image viewing.
- Preserve the current tab + snapshot model (the shared
PlaywrightState in playwright/state.ts) when adding stateful browser actions.
Test pattern:
- Add tests in
packages/webapp/tests/ mirroring the command's src/ path (for example tests/shell/supplemental-commands/playwright-command.test.ts).
- Put pure helper coverage in
shared.test.ts.
- Prefer focused command-level assertions over large integration fixtures.
Alignment with official playwright-cli:
When adding a new playwright-cli subcommand, also update
packages/webapp/src/shell/supplemental-commands/playwright/slicc-commands.json
and run node packages/dev-tools/tools/playwright-cli-sync.mjs to confirm the gap
is closed. If you're implementing a command that the official CLI already has, cross-
reference its args and flags in help.json first. Full workflow: docs/playwright-cli-sync.md.
Reference files: packages/webapp/src/shell/supplemental-commands/playwright-command.ts (dispatcher) and packages/webapp/src/shell/supplemental-commands/playwright/ (handlers + helpers), packages/webapp/src/shell/supplemental-commands/serve-command.ts, packages/webapp/src/shell/supplemental-commands/sprinkle-command.ts
5. Add a Scoop-Management Tool
When: To add a messaging or multi-scoop management tool.
Files to modify:
- Modify:
packages/webapp/src/scoops/scoop-management-tools.ts
Implementation:
export function createScoopManagementTools(config: ScoopManagementToolsConfig): ToolDefinition[] {
const tools: ToolDefinition[] = [];
if (scoop.isCone && config.onMySpecialCallback) {
tools.push({
name: 'my_special_tool',
description: 'Description of what this tool does.',
inputSchema: {
type: 'object',
properties: {
param1: {
type: 'string',
description: 'First parameter',
},
},
required: ['param1'],
},
execute: async (input) => {
const { param1 } = input as { param1: string };
try {
const result = await config.onMySpecialCallback(param1);
return { content: result };
} catch (err) {
const msg = err ? err. : (err);
{ : , : };
}
},
});
}
tools;
}
Interface:
interface ScoopManagementToolsConfig {
scoop: RegisteredScoop;
onSendMessage: (text: string, sender?: string) => void;
getScoops: () => RegisteredScoop[];
onFeedScoop?: (scoopJid: string, prompt: string) => Promise<void>;
onScoopScoop?: (scoop: Omit<RegisteredScoop, 'jid'>) => Promise<RegisteredScoop>;
onDropScoop?: (scoopJid: string) => Promise<void>;
onSetGlobalMemory?: (content: string) => Promise<void>;
getGlobalMemory?: () => Promise<string>;
}
interface RegisteredScoop {
jid: string;
: ;
: ;
: ;
: ;
}
Cone vs Universal:
- Cone-only: Guarded by
if (scoop.isCone && callback) — e.g., feed_scoop, scoop_scoop, drop_scoop
- Universal: Available to all scoops — e.g.,
send_message
Add callback to ScoopContextCallbacks:
export interface ScoopContextCallbacks {
onMySpecialCallback?: (param: string) => Promise<string>;
}
Wire in Orchestrator:
const scoopManagementConfig: ScoopManagementToolsConfig = {
onMySpecialCallback: async (param) => {
},
};
Test pattern:
import { describe, it, expect, vi } from 'vitest';
import { createScoopManagementTools } from './scoop-management-tools.js';
describe('my_special_tool', () => {
it('should execute correctly', async () => {
const mockCallback = vi.fn().mockResolvedValue('result');
const tools = createScoopManagementTools({
scoop: { isCone: true, folder: 'test' },
onMySpecialCallback: mockCallback,
});
const tool = tools.find((t) => t.name === 'my_special_tool');
expect(tool).toBeDefined();
const result = await tool!.execute({ param1: 'test' });
expect(result.content).toContain('result');
});
});
Reference file: packages/webapp/src/scoops/scoop-management-tools.ts
6. Add a UI Panel
When: To add a new tab or section in the UI (e.g., a settings panel, network monitor).
Architecture note: The legacy Layout/ChatPanel UI was removed during the
web-components migration. The current UI shell is built on @slicc/webcomponents
(see packages/webcomponents/). New panels are web components mounted via the
wc-shell.ts / wc-live.ts controllers in packages/webapp/src/ui/wc/.
Files to create/modify:
- Create:
packages/webcomponents/src/my-panel.ts (web component)
- Modify:
packages/webcomponents/src/index.ts (register the element)
- Modify:
packages/webapp/src/ui/wc/wc-live.ts (mount and wire the panel)
The @slicc/webcomponents library provides the UI primitives (Storybook +
@vitest/browser for testing). The packages/webapp/src/ui/wc/ controllers
handle mounting, scoop lifecycle events, and leader/follower behavior.
Test pattern: Web component tests use @vitest/browser (real Chromium):
npm test -w @slicc/webcomponents
Reference files:
packages/webcomponents/src/ — existing web component implementations
packages/webapp/src/ui/wc/wc-live.ts — leader UI shell wiring
packages/webapp/src/ui/wc/wc-follower.ts — follower UI shell wiring
7. Add a Skill
When: To ship reusable agent instructions as a markdown file.
Files to create:
- Create:
packages/vfs-root/workspace/skills/my-skill/SKILL.md
- Optional:
packages/vfs-root/workspace/skills/my-skill/helper.jsh (executable script)
Implementation:
---
name: my-skill
description: Teaches the agent how to do X
---
# My Skill
You are an expert in [domain]. Your role is to [responsibility].
## Key Principles
1. Always [principle 1]
2. Consider [principle 2]
## Example
When the user asks for X, follow this approach:
- Step 1: [description]
- Step 2: [description]
- Step 3: [description]
Use the `bash` tool to run commands. Use `read_file` to inspect files.
## Output Format
Always provide:
- A brief summary
- Code blocks (when applicable)
- Relevant file paths
How it works:
Skills are auto-discovered from native /workspace/skills/ plus any accessible .agents/skills/*/SKILL.md and .claude/skills/*/SKILL.md directories anywhere in the reachable VFS during scoop initialization. Headers are shown by default; full content is loaded on demand.
With executable script:
Run `my-skill-cmd arg1` to process files:
const args = process.argv.slice(2);
console.log(`Processing: ${args.join(', ')}`);
Discovery:
During ScoopContext.init(), SLICC starts from /workspace/skills/ (cone) or /scoops/{folder}/workspace/skills/ (scoop), then also considers any accessible .agents/skills/*/SKILL.md and .claude/skills/*/SKILL.md roots elsewhere in that runtime's reachable VFS. The agent's system prompt includes discovered skill headers and can request full content via read_file.
Only native /workspace/skills/ entries are install-managed by SLICC. Compatibility-discovered .agents and .claude skills remain read-only unless you explicitly copy/package them into the native skills directory.
Test pattern:
Skills are narrative instructions; test by verifying they load correctly:
import { describe, it, expect } from 'vitest';
import { loadSkills } from './skills.js';
import { VirtualFS } from '../fs/index.js';
describe('loadSkills', () => {
it('should load a skill with metadata', async () => {
const fs = new VirtualFS();
await fs.writeFile(
'/workspace/skills/test/SKILL.md',
'---\nname: test-skill\ndescription: Test\n---\nContent'
);
const skills = await loadSkills(fs, '/workspace/skills');
expect(skills[0].metadata.name).toBe('test-skill');
});
});
Reference file: packages/webapp/src/scoops/skills.ts, packages/vfs-root/workspace/skills/
8. Add a Provider
Providers come from three sources:
- Pi-ai auto-discovery:
getProviders() returns all pi-ai providers automatically — no files needed. Filtered by packages/dev-tools/providers.build.json (include: ["*"] = all, exclude: ["*"] = none).
- Built-in extensions:
packages/webapp/src/providers/built-in/*.ts — only for providers needing custom register() functions (e.g., bedrock-camp). Also filtered by packages/dev-tools/providers.build.json.
- External:
packages/webapp/providers/*.ts (gitignored within the webapp package) — always included, never filtered. For custom OAuth providers, corporate proxies, etc. Some providers (e.g., adobe.ts) are explicitly un-gitignored and tracked in version control.
Built-in and external modules export config: ProviderConfig and optionally register(): void.
8a. Add an API-Key Provider
When: To support a new LLM provider that uses an API key (e.g., Groq, Hugging Face).
Most providers need no files at all. Pi-ai auto-discovers its providers via getProviders(), and provider-settings.ts generates a fallback config (display name derived from ID, requiresApiKey: true, requiresBaseUrl: false). The provider appears in the Settings UI automatically.
Only create a file in packages/webapp/src/providers/built-in/ if the provider needs a custom register() function (e.g., custom stream functions). See packages/webapp/src/providers/built-in/bedrock-camp.ts for an example.
For external providers (typically gitignored), create packages/webapp/providers/my-provider.ts:
import type { ProviderConfig } from '../src/providers/types.js';
export const config: ProviderConfig = {
id: 'my-provider',
name: 'My Provider',
description: 'Models via My Provider API',
requiresApiKey: true,
apiKeyPlaceholder: 'your-api-key-here',
apiKeyEnvVar: 'MY_PROVIDER_API_KEY',
requiresBaseUrl: false,
};
export function register(): void {
}
External providers in packages/webapp/providers/ are always included (never filtered by packages/dev-tools/providers.build.json).
8b. Add an OAuth Provider (Corporate Proxy / SSO)
When: To support a provider that authenticates via OAuth (implicit grant or PKCE) — typically a corporate LLM proxy behind SSO.
Files to create:
packages/webapp/providers/my-corp.ts (external, gitignored)
packages/webapp/providers/my-corp-config.json (optional, for client ID / endpoints)
Implementation:
import type { ProviderConfig, OAuthLauncher } from '../src/providers/types.js';
import { registerApiProvider, streamAnthropic } from '@earendil-works/pi-ai';
import type { Api, Model, Context } from '@earendil-works/pi-ai';
import { saveOAuthAccount, getAccounts } from '../src/ui/provider-settings.js';
const isExtension = typeof chrome !== 'undefined' && !!(chrome as any)?.runtime?.id;
const configFiles = import.meta.glob('/packages/webapp/providers/my-corp-config.json', {
eager: true,
import: 'default',
}) as Record<
string,
{ clientId: string; proxyEndpoint: string; redirectUri?: string; extensionRedirectUri?: string }
>;
const corpConfig = configFiles['/packages/webapp/providers/my-corp-config.json'] ?? {
: ,
: ,
};
: = {
: ,
: ,
: ,
: ,
: ,
: ,
: (: , : ) => {
redirectUri = isExtension
? (corpConfig. ??
)
: (corpConfig. ?? );
state = crypto.();
params = ({
: corpConfig.,
: ,
: redirectUri,
: ,
state,
});
authorizeUrl = ;
redirectUrl = (authorizeUrl);
(!redirectUrl) ;
fragment = (redirectUrl.(redirectUrl.() + ));
(fragment.() !== state) ;
accessToken = fragment.();
(!accessToken) ;
({
: ,
accessToken,
: .() + (fragment.() ?? , ) * ,
});
();
},
: () => {
({ : , : });
},
};
(): {
({
: ,
: {
account = ().( a. === );
proxyModel = {
...model,
: corpConfig.,
: ,
};
(proxyModel , context, {
...options,
: account?.,
});
},
});
}
How the OAuth flow works:
- User clicks "Login with My Corp" in the Settings dialog
provider-settings.ts calls config.onOAuthLogin(launcher, onSuccess)
- The provider builds its authorize URL and calls
launcher(authorizeUrl)
- The generic
OAuthLauncher (from packages/webapp/src/providers/oauth-service.ts) handles transport:
- CLI: Opens popup → IDP login → redirects to
https://www.sliccy.ai/auth/callback → relay page decodes state (port, path, nonce) → redirects to http://localhost:{port}/auth/callback → callback page postMessages the redirect URL back → popup closes
- Extension: Sends
oauth-request to service worker → chrome.identity.launchWebAuthFlow → returns redirect URL with token in fragment
- The provider extracts the token from the redirect URL and calls
saveOAuthAccount()
onSuccess() re-renders the accounts list showing the logged-in state
Key files:
packages/webapp/src/providers/types.ts — ProviderConfig (with onOAuthLogin, onOAuthLogout), OAuthLauncher type
packages/webapp/src/providers/oauth-service.ts — createOAuthLauncher() factory (CLI popup vs extension chrome.identity)
packages/webapp/src/ui/provider-settings.ts — Calls config.onOAuthLogin(launcher, onSuccess) when login button clicked
packages/node-server/src/index.ts — /auth/callback route (reads query params + fragment, postMessages to opener)
packages/chrome-extension/src/service-worker.ts — handleOAuthRequest() (generic chrome.identity.launchWebAuthFlow)
Dual-mode redirect URIs:
| Mode | Redirect URI | Registration |
|---|
| CLI | https://www.sliccy.ai/auth/callback | Register with your OAuth provider/IdP |
| Extension | https://<extension-id>.chromiumapp.org/ | Register with your OAuth provider/IdP |
The CLI redirect URI uses the sliccy.ai relay which decodes the OAuth state parameter to find the localhost port. Encode {port, path, nonce} as base64 JSON in the state param. See packages/webapp/providers/adobe.ts for the pattern.
Type:
interface ProviderConfig {
id: string;
name: string;
description: string;
requiresApiKey: boolean;
apiKeyPlaceholder?: string;
apiKeyEnvVar?: string;
requiresBaseUrl: boolean;
baseUrlPlaceholder?: string;
baseUrlDescription?: string;
isOAuth?: boolean;
onOAuthLogin?: (launcher: OAuthLauncher, onSuccess: () => void) => Promise<void>;
onOAuthLogout?: () => Promise<void>;
modelOverrides?: Record<string, ModelMetadata>;
getModelIds?: () => Array<{ id: string; name?: string } & ModelMetadata>;
}
{
?: | ;
?: ;
?: ;
?: ;
?: [];
}
= < | >;
requiresBaseUrl for OAuth providers: By default, the base URL field is hidden for OAuth providers. Set requiresBaseUrl: true to show it — useful for providers where the proxy endpoint is configurable at runtime. The base URL is saved to the account before onOAuthLogin is called, so the provider can read it via getBaseUrlForProvider(). The saveOAuthAccount() function preserves the existing baseUrl through re-logins.
getModelIds: When present, getProviderModels() uses this instead of returning all Anthropic models. Each ID is resolved against the Anthropic model registry; unknown IDs get fallback model objects with sensible defaults. Can return optional ModelMetadata fields per model — these override pi-ai defaults. Set api: 'openai' to route a model through streamOpenAICompletions instead of streamAnthropic.
modelOverrides: Static per-model overrides applied to all models for this provider. Useful for config-only providers (like Azure AI Foundry) that can't implement getModelIds() but need custom context windows. Example: modelOverrides: { 'claude-opus-4-6': { context_window: 1000000 } }.
refreshModels (optional, (accessToken?) => Promise<void>): the async populate step for a dynamic model list. getModelIds() is synchronous and only reads caches; refreshModels is where the provider fetches its /v1/models-style list, caches it, and persists the enriched result to localStorage (so cold consumers — notably the cloud cone's kernel worker, which reads localStorage — see the full set + metadata on first resolve). Normally this runs inside onOAuthLogin. Floats that inject an account without an interactive login (the cloud cone, via applyHostedAccounts) must call it explicitly — prewarmHostedModels in ui/hosted-config-apply.ts does this before applying the account (the account write triggers the worker's model resolution, so the list must be warm first). The optional accessToken lets callers pre-warm before the account is persisted. Without it, an OAuth model id pi-ai's registry doesn't know (e.g. claude-opus-4-8) still routes through the provider (see resolution note below) but with default metadata until the list warms.
OAuth-safe resolution: resolveModelById / resolveCurrentModel (ui/provider-settings.ts) never fall back to a native Anthropic model for an OAuth/custom provider. An unknown model id is routed through the provider (api: '${providerId}-anthropic') via buildProviderRoutedModel — otherwise the provider's token (e.g. an Adobe IMS token) would be sent to api.anthropic.com and rejected with 401 invalid x-api-key.
Three-layer merge: Model capabilities resolve as pi-ai registry (defaults) → modelOverrides (static overrides) → getModelIds() metadata (dynamic, highest priority). Each layer only overrides fields it provides.
Model ID pitfall: Use pi-ai alias IDs (e.g., claude-opus-4-6) not dated IDs (e.g., claude-opus-4-6-20250626). In the browser bundle, getModel() returns undefined for unknown IDs instead of throwing, and { ...undefined } silently produces {}. The alias resolves to a full model from the registry with all required fields.
Base URL validation: When requiresBaseUrl: true is set on an OAuth provider and no build-time default exists (empty proxyEndpoint in config), the login button validates that a URL was entered. Users cannot proceed without providing a proxy endpoint.
Test pattern:
OAuth flow is runtime-dependent (browser popups, chrome.identity). Test the provider's token extraction and account saving logic in isolation:
import { describe, it, expect } from 'vitest';
describe('my-corp provider', () => {
it('extracts token from redirect URL', () => {
const url = 'https://sso.mycorp.com/callback#access_token=abc123&expires_in=3600';
const fragment = new URLSearchParams(url.slice(url.indexOf('#') + 1));
expect(fragment.get('access_token')).toBe('abc123');
});
});
Reference files: packages/webapp/src/providers/oauth-service.ts, packages/webapp/src/providers/types.ts, packages/webapp/src/ui/provider-settings.ts
Integration Checklist
When adding a feature:
Build & Test
npm run typecheck
npm run test
npm run dev
npm run build -w @slicc/chrome-extension
9. Add Interactive Tool UI (Approval Dialogs, Forms)
When: A shell command or tool needs user interaction before proceeding (e.g., permission approval, file picker, form input). Tool UI solves the "user gesture" problem — browser APIs like showDirectoryPicker() require a user click, but agent-driven tool calls have no gesture context. For the broader gate-pattern context (sudo, device gates, OS capture gates), see docs/approvals.md.
Files to modify:
- Your command file (e.g.,
packages/webapp/src/fs/mount-commands.ts)
- Import from:
packages/webapp/src/tools/tool-ui.ts
How it works:
- Tool execution sets up a context with
onUpdate callback (handled automatically by tool-adapter.ts)
- Shell commands call
showToolUIFromContext() to render interactive HTML in the chat
- User clicks a button → callback runs with user gesture context → can call restricted APIs
- Promise resolves with user's action/data
Implementation (from mount command):
import { getToolExecutionContext, showToolUIFromContext } from '../tools/tool-ui.js';
async function execute(args: string[]): Promise<ShellResult> {
const toolContext = getToolExecutionContext();
if (toolContext) {
const safePath = targetPath.replace(
/[&<>"']/g,
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] ?? c
);
const result = await showToolUIFromContext({
html: `
<div class="tool-ui">
<p>The agent wants to access <code>${safePath}</code></p>
<div class="tool-ui__actions">
<button class="tool-ui__btn tool-ui__btn--primary" data-action="approve">
Approve
</button>
<button class="tool-ui__btn tool-ui__btn--secondary" data-action="deny">
Deny
</button>
</div>
</div>
`,
onAction: async (action) => {
(action === ) {
handle = .();
{ : , handle };
}
{ : };
},
});
(!result?.) {
{ : , : , : };
}
} {
handle = .();
}
}
HTML conventions:
- Wrap content in
<div class="tool-ui">
- Use
data-action="actionName" on buttons for click handling
- Use
data-action-data='{"key":"value"}' for additional data (JSON)
- Available button classes:
.tool-ui__btn--primary, .tool-ui__btn--secondary
- Forms: add
data-action="submit" to form, fields become action data
Key functions (packages/webapp/src/tools/tool-ui.ts):
getToolExecutionContext(): ToolExecutionContext | null
showToolUIFromContext(request: {
html: string;
onAction?: (action: string, data?: unknown) => Promise<unknown> | unknown;
}): Promise<unknown | null>
showToolUI(request: ToolUIRequest, onUpdate: OnUpdateCallback): Promise<unknown>
Lifecycle:
- Tool calls
showToolUIFromContext() → UI appears in chat (tool call auto-expands)
- User clicks button with
data-action → onAction callback fires with gesture context
- Callback return value resolves the
showToolUIFromContext() promise
- UI is automatically cleaned up when tool execution ends
Extension vs CLI mode:
- CLI mode: HTML rendered directly in DOM with click handlers
- Extension mode: HTML rendered in CSP-exempt sandbox iframe, actions posted via
postMessage
Both modes handle data-action clicks and form submissions identically.
Common Patterns
Error handling: Wrap async operations in try/catch. Return { content: errorMsg, isError: true } for tools.
Logging: Import createLogger('namespace') from packages/webapp/src/core/logger.js. Logs are filtered by level (DEBUG in dev, ERROR in prod).
VFS access: All core layers have access to VirtualFS. Scoops get RestrictedFS (path-based ACL).
Shell commands: Prefer shell commands (bash tool) for new capabilities. Dedicated tools only if the capability needs binary data (browser screenshots, network recording).
Browser automation: Use playwright-cli / playwright / puppeteer for tab control. Use serve <dir> for app directories and open for single preview files.
Resources