Agent-first, implementation-focused guide to extending SLICC. Each guide shows exact file paths, code interfaces, and wiring patterns.
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:
// packages/webapp/src/shell/supplemental-commands/my-command.ts
import { defineCommand } from 'just-bash';
import type { Command, CommandContext } from 'just-bash';
export function createMyCommand(): Command {
return defineCommand('mycommand', async (args, ctx) => {
// args: string[] of arguments
// ctx: CommandContext { fs, env, cwd, getRegisteredCommands }
try {
// Your logic here
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():
// packages/webapp/src/shell/supplemental-commands/index.ts
import { createMyCommand } from './my-command.js';
export function createSupplementalCommands(options: SupplementalCommandsConfig = {}): Command[] {
return [
// ... existing commands ...
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:
// packages/webapp/tests/shell/supplemental-commands/my-command.test.ts
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
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:
// packages/vfs-root/workspace/skills/my-skill/my-script.jsh
// The script has access to:
// - process: { argv, env, cwd(), exit(code), stdout.write(), stderr.write() }
// - console: { log, info, warn, error }
// - require('fs'): { readFile, writeFile, readDir, mkdir, rm, stat, exists }
// - require('sliccy:<name>'): exec / http / browser / skill / cli / ...
const fs = require('fs');
const args = process.argv.slice(2); // Skip 'node' and script path
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(`Error: ${err.message}`);
process.exit(1);
}
})();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 arg2Execution modes:
- CLI mode: Uses
AsyncFunctionconstructor, 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:
// packages/webapp/tests/shell/supplemental-commands/my-command.test.ts
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
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:
// packages/webapp/src/tools/my-tool.ts
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['x'] as string;
const y = input['y'] as number | undefined;
log.debug('Execute', { x, y });
try {
// Your logic
const result = await doSomething(x, y, dependency);
return {
content: `Result: ${result}`,
isError: false,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.error('Error', { x, error: message });
return {
content: `Error: ${message}`,
isError: true,
};
}
},
};
}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; // Allow additional schema fields
}
interface ToolResult {
content: string;
isError?: boolean;
}Wire into ScoopContext:
// packages/webapp/src/scoops/scoop-context.ts — in the init() method
const legacyTools = [
// ... existing tools ...
createMyTool(dependency),
];Test pattern:
// packages/webapp/tests/tools/my-tool.test.ts
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
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 inplaywright/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.tsis 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 theplaywrightHandlersmap. - Reuse shared preview helpers for VFS URLs instead of manually constructing
/preview/...paths. - Use
serve <dir>for app directories (defaultindex.html, optional--entry) andopenfor single files, URLs, downloads, or inline image viewing. - Preserve the current tab + snapshot model (the shared
PlaywrightStateinplaywright/state.ts) when adding stateful browser actions.
Test pattern:
- Add tests in
packages/webapp/tests/mirroring the command'ssrc/path (for exampletests/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
When: To add a messaging or multi-scoop management tool.
Files to modify:
- Modify:
packages/webapp/src/scoops/scoop-management-tools.ts
Implementation:
// packages/webapp/src/scoops/scoop-management-tools.ts — in createScoopManagementTools()
export function createScoopManagementTools(config: ScoopManagementToolsConfig): ToolDefinition[] {
const tools: ToolDefinition[] = [];
// ... existing tools (send_message, feed_scoop, etc.) ...
// Cone only: my_special_tool
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 instanceof Error ? err.message : String(err);
return { content: `Failed: ${msg}`, isError: true };
}
},
});
}
return tools;
}Interface:
interface ScoopManagementToolsConfig {
scoop: RegisteredScoop;
onSendMessage: (text: string, sender?: string) => void;
getScoops: () => RegisteredScoop[];
// Cone-only callbacks:
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; // Unique ID
name: string;
folder: string;
isCone: boolean;
assistantLabel: 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:
// packages/webapp/src/scoops/scoop-context.ts
export interface ScoopContextCallbacks {
// ... existing callbacks ...
onMySpecialCallback?: (param: string) => Promise<string>;
}Wire in Orchestrator:
// packages/webapp/src/scoops/orchestrator.ts
const scoopManagementConfig: ScoopManagementToolsConfig = {
// ... existing config ...
onMySpecialCallback: async (param) => {
// Implementation
},
};Test pattern:
// packages/webapp/tests/scoops/scoop-management-tools.test.ts
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,
// ... other config ...
});
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
When: To add a new tab or section in the UI (e.g., a settings panel, network monitor).
Files to create/modify:
- Create:
packages/webapp/src/ui/my-panel.ts - Modify:
packages/webapp/src/ui/main.ts
Implementation:
// packages/webapp/src/ui/my-panel.ts
export class MyPanel {
private container: HTMLElement;
private contentEl!: HTMLElement;
constructor(container: HTMLElement) {
this.container = container;
this.render();
}
private render(): void {
this.container.className = 'my-panel';
const header = document.createElement('div');
header.className = 'my-panel__header';
header.textContent = 'My Panel';
this.container.appendChild(header);
this.contentEl = document.createElement('div');
this.contentEl.className = 'my-panel__content';
this.container.appendChild(this.contentEl);
}
setSelectedScoop(jid: string | null): void {
// Called when scoop changes
this.refresh();
}
async refresh(): Promise<void> {
// Update panel content
this.contentEl.textContent = 'Loading...';
try {
// Fetch data
const data = await this.fetchData();
this.contentEl.textContent = JSON.stringify(data);
} catch (err) {
this.contentEl.textContent = `Error: ${err}`;
}
}
private async fetchData(): Promise<unknown> {
// Your logic
return {};
}
}Wire into Layout (Standalone mode):
// packages/webapp/src/ui/layout.ts
import { MyPanel } from './my-panel.js';
export interface LayoutPanels {
chat: ChatPanel;
terminal: TerminalPanel;
fileBrowser: FileBrowserPanel;
memory: MemoryPanel;
myPanel: MyPanel; // Add new panel
scoops: ScoopsPanel;
}
export class Layout {
private myPanelContainer!: HTMLElement;
constructor(root: HTMLElement, isExtension = false) {
// ... existing code ...
}
private createSplitLayout(): void {
// ... existing code ...
// Create my-panel in bottom section
this.myPanelContainer = document.createElement('div');
this.panels.myPanel = new MyPanel(this.myPanelContainer);
}
setSelectedScoop(scoop: RegisteredScoop | null): void {
// ... existing code ...
this.panels.myPanel.setSelectedScoop(scoop?.jid ?? null);
}
}Wire into Layout (Extension/Tabbed mode):
// packages/webapp/src/ui/layout.ts — in createTabbedLayout()
const tabIds: TabId[] = ['chat', 'terminal', 'files', 'memory', 'myPanel'];
// Create tab button and container
const myPanelBtn = document.createElement('button');
myPanelBtn.className = 'layout__tab-btn';
myPanelBtn.textContent = 'My Panel';
tabsContainer.appendChild(myPanelBtn);
const myPanelContainer = document.createElement('div');
myPanelContainer.className = 'layout__tab-content';
this.tabContainers.set('myPanel', myPanelContainer);
this.panels.myPanel = new MyPanel(myPanelContainer);CSS:
/* packages/webapp/src/ui/styles.css */
.my-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.my-panel__header {
padding: 10px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
font-weight: bold;
}
.my-panel__content {
flex: 1;
overflow-y: auto;
padding: 10px;
}Test pattern:
Panel tests are DOM-heavy; test interactions and state manually in extension/standalone mode rather than in vitest:
// packages/webapp/tests/ui/my-panel.test.ts — only test non-DOM logic
import { describe, it, expect, vi } from 'vitest';
describe('MyPanel', () => {
it('should initialize', () => {
const container = document.createElement('div');
const panel = new MyPanel(container);
expect(container.querySelector('.my-panel')).toBeDefined();
});
});Reference file: packages/webapp/src/ui/wc/wc-live.ts
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 pathsHow 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:
# packages/vfs-root/workspace/skills/my-skill/SKILL.md
## Command: my-skill-cmd
Run `my-skill-cmd arg1` to process files:
// packages/vfs-root/workspace/skills/my-skill/my-skill-cmd.jsh
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:
// packages/webapp/tests/scoops/skills.test.ts
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();
// Write a skill file
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/
Providers come from three sources:
- Pi-ai auto-discovery:
getProviders()returns all pi-ai providers automatically — no files needed. Filtered bypackages/dev-tools/providers.build.json(include: ["*"]= all,exclude: ["*"]= none). - Built-in extensions:
packages/webapp/src/providers/built-in/*.ts— only for providers needing customregister()functions (e.g., bedrock-camp). Also filtered bypackages/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.
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:
// 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,
};
// Optional: register custom stream functions with pi-ai
export function register(): void {
// registerApiProvider({ api: 'my-provider' as Api, stream: ..., streamSimple: ... });
}External providers in packages/webapp/providers/ are always included (never filtered by packages/dev-tools/providers.build.json).
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:
// packages/webapp/providers/my-corp.ts
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;
// Load config from a gitignored JSON file
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'] ?? {
clientId: '',
proxyEndpoint: '',
};
export const config: ProviderConfig = {
id: 'my-corp',
name: 'My Corp',
description: 'Claude via corporate proxy — login with SSO',
requiresApiKey: false,
requiresBaseUrl: false,
isOAuth: true,
onOAuthLogin: async (launcher: OAuthLauncher, onSuccess: () => void) => {
// Build the redirect URI based on runtime
const redirectUri = isExtension
? (corpConfig.extensionRedirectUri ??
`https://${(chrome as any).runtime.id}.chromiumapp.org/`)
: (corpConfig.redirectUri ?? `${window.location.origin}/auth/callback`);
const params = new URLSearchParams({
client_id: corpConfig.clientId,
response_type: 'token',
redirect_uri: redirectUri,
scope: 'openid profile',
});
const authorizeUrl = `https://sso.mycorp.com/authorize?${params}`;
// Launch the OAuth flow — launcher handles CLI popup vs extension chrome.identity
const redirectUrl = await launcher(authorizeUrl);
if (!redirectUrl) return; // User cancelled or timed out
// Extract token from redirect URL (provider-specific: implicit grant has token in fragment)
const fragment = new URLSearchParams(redirectUrl.slice(redirectUrl.indexOf('#') + 1));
const accessToken = fragment.get('access_token');
if (!accessToken) return;
// Save the OAuth account — this makes the provider "logged in"
saveOAuthAccount({
providerId: 'my-corp',
accessToken,
tokenExpiresAt: Date.now() + parseInt(fragment.get('expires_in') ?? '86400', 10) * 1000,
});
onSuccess(); // Re-render the accounts list
},
onOAuthLogout: async () => {
// Optionally revoke the token with your IdP
saveOAuthAccount({ providerId: 'my-corp', accessToken: '' });
},
};
// --- Silent token renewal (optional) ---
// If your IdP supports prompt=none (silent re-auth via session cookie),
// you can implement automatic token renewal in getValidAccessToken():
//
// 1. Check if token is about to expire (e.g., < 60s remaining)
// 2. Build an authorize URL with prompt=none appended
// 3. Call createOAuthLauncher() — it handles CLI popup, extension
// chrome.identity, and Electron relay automatically
// 4. Extract new token from the redirect URL
// 5. Save via saveOAuthAccount(), return the new token
//
// See packages/webapp/providers/adobe.ts silentRenewToken() for a working example.
// If renewal fails, fall back to throwing "session expired".
// Register custom stream function that proxies through the corporate endpoint
export function register(): void {
registerApiProvider({
api: 'my-corp-anthropic' as Api,
stream: (model: Model<Api>, context: Context, options: any = {}) => {
const account = getAccounts().find((a) => a.providerId === 'my-corp');
const proxyModel = {
...model,
baseUrl: corpConfig.proxyEndpoint,
api: 'anthropic-messages' as Api,
};
return streamAnthropic(proxyModel as any, context, {
...options,
apiKey: account?.accessToken,
});
},
});
}How the OAuth flow works:
- User clicks "Login with My Corp" in the Settings dialog
provider-settings.tscallsconfig.onOAuthLogin(launcher, onSuccess)- The provider builds its authorize URL and calls
launcher(authorizeUrl) - The generic
OAuthLauncher(frompackages/webapp/src/providers/oauth-service.ts) handles transport:- CLI: Opens popup → IDP login → redirects to
https://www.sliccy.ai/auth/callback→ relay page decodesstate(port, path, nonce) → redirects tohttp://localhost:{port}/auth/callback→ callback page postMessages the redirect URL back → popup closes - Extension: Sends
oauth-requestto service worker →chrome.identity.launchWebAuthFlow→ returns redirect URL with token in fragment
- CLI: Opens popup → IDP login → redirects to
- 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(withonOAuthLogin,onOAuthLogout),OAuthLaunchertypepackages/webapp/src/providers/oauth-service.ts—createOAuthLauncher()factory (CLI popup vs extension chrome.identity)packages/webapp/src/ui/provider-settings.ts— Callsconfig.onOAuthLogin(launcher, onSuccess)when login button clickedpackages/node-server/src/index.ts—/auth/callbackroute (reads query params + fragment, postMessages to opener)packages/chrome-extension/src/service-worker.ts—handleOAuthRequest()(genericchrome.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; // shown for non-OAuth; also shown for OAuth providers when true
baseUrlPlaceholder?: string;
baseUrlDescription?: string;
isOAuth?: boolean;
onOAuthLogin?: (launcher: OAuthLauncher, onSuccess: () => void) => Promise<void>;
onOAuthLogout?: () => Promise<void>;
/** Static per-model capability overrides. */
modelOverrides?: Record<string, ModelMetadata>;
/** Return model IDs with optional metadata (resolved against Anthropic registry). */
getModelIds?: () => Array<{ id: string; name?: string } & ModelMetadata>;
}
/** Wire format for model capabilities (snake_case, merged into camelCase Model objects). */
interface ModelMetadata {
api?: 'anthropic' | 'openai'; // stream function routing
context_window?: number; // context window in tokens
max_tokens?: number; // max output tokens
reasoning?: boolean; // supports thinking/reasoning
input?: string[]; // input modalities (['text', 'image'])
}
type OAuthLauncher = (authorizeUrl: string) => Promise<string | null>;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
When adding a feature:
- Core logic implemented with error handling
- Test file in
packages/*/tests/mirroring thesrc/structure - Pure-logic tests added (avoid DOM/chrome.* testing in vitest unless necessary)
- Extension mode compatibility verified (CSP, chrome.runtime.getURL, sandbox iframe if needed)
- Dual-mode tested (CLI + extension)
- Logging added (
createLogger('namespace')) - CLAUDE.md updated if architectural pattern is new
- No sensitive data logged or stored in localStorage unencrypted
# Type-check both browser and CLI
npm run typecheck
# Run tests
npm run test
# Standalone dev
npm run dev
# Extension dev
npm run build -w @slicc/chrome-extension
# Then load dist/extension in chrome://extensionsWhen: 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
onUpdatecallback (handled automatically bytool-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> {
// Check if running in agent context (no user gesture)
const toolContext = getToolExecutionContext();
if (toolContext) {
// Agent-driven: show approval UI
const result = await showToolUIFromContext({
html: `
<div class="tool-ui">
<p>The agent wants to access <code>${targetPath}</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) => {
if (action === 'approve') {
// Runs with user gesture! Can call showDirectoryPicker(), etc.
const handle = await window.showDirectoryPicker();
return { approved: true, handle };
}
return { approved: false };
},
});
if (!result?.approved) {
return { stdout: '', stderr: 'User denied', exitCode: 1 };
}
// Use result.handle...
} else {
// Terminal/user-driven: has gesture, call API directly
const handle = await window.showDirectoryPicker();
}
}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):
// Get current tool execution context (null if not in a tool)
getToolExecutionContext(): ToolExecutionContext | null
// Show UI and wait for user action (returns null if no context)
showToolUIFromContext(request: {
html: string;
onAction?: (action: string, data?: unknown) => Promise<unknown> | unknown;
}): Promise<unknown | null>
// Lower-level: show UI with explicit onUpdate callback
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→onActioncallback 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.
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.
- pi-mono architecture: https://github.com/earendil-works/pi-mono
- just-bash: https://github.com/jotaen/just-bash
- Isomorphic-git: https://isomorphic-git.org/
- LightningFS: https://github.com/steverice/lightning-fs