Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
8c5dfc0
feat(console): agent templates from the registry, Switch expert bundl…
abeldantas Sep 10, 2026
7865846
feat: extractable template pattern on the registry + wizard bridge
abeldantas Sep 11, 2026
036300c
fix(switch-expert): the skill is already loaded; the README owns "wha…
amaudruz Aug 24, 2026
015da74
fix(switch-expert): the working directory is fixed, and the addressin…
amaudruz Aug 24, 2026
7398a5e
docs(switch-expert): lean on the README's levels for "why Switch"
amaudruz Aug 24, 2026
ee30b49
Accept agent templates in the registry linter, and drop the repo/sour…
abeldantas Sep 14, 2026
cd66128
Switch expert as an agent template, and a second expert to prove the …
abeldantas Sep 14, 2026
efad637
Create a working Switch expert from the Templates view in two clicks …
abeldantas Sep 14, 2026
cc44e23
Strip front matter from template instructions before the Console rend…
abeldantas Sep 14, 2026
10e9790
Make the template dialog a confirmation, and the listing say what eac…
abeldantas Sep 14, 2026
bc75757
Check the room can be spoken in before creating it, then open the ses…
abeldantas Sep 14, 2026
a6154dc
Create a template agent into the room you are in, and keep its templa…
abeldantas Sep 14, 2026
0855cc1
Suggest and prepare the working directory on a remote host too (CHOO-…
abeldantas Sep 14, 2026
95653ac
Suggest a directory next door when the usual one already holds an age…
abeldantas Sep 14, 2026
b090196
Keep the SSH context alive until the remote probes finish, and let "f…
abeldantas Sep 14, 2026
9de59b9
Lay the template summary out as labelled rows instead of prose (CHOO-…
abeldantas Sep 14, 2026
7208ae4
Name the room and nothing else, and say what to do about a server wit…
abeldantas Sep 14, 2026
4cfc2cd
Drop the wizard's not-found bridge, and say what the linked account i…
abeldantas Sep 16, 2026
6fc746d
Bring the recently used templates, the example, and Save to server in…
abeldantas Sep 16, 2026
8de7bb3
Give each template a page, fold the built-in and server copies into o…
abeldantas Sep 16, 2026
c638b0e
Ask twice before removing a template from the server, without the sub…
abeldantas Sep 16, 2026
5c33d3d
One way in for templates of either kind, with the listing as the plac…
abeldantas Sep 16, 2026
3ca1adf
Make the import tile read as a drop zone, and carry a room's descript…
abeldantas Sep 16, 2026
ffd9616
Ask for a name and a line when saving a template to the server (CHOO-…
abeldantas Sep 16, 2026
6024e6e
Group the Templates page by origin, with search, kind filters and Onl…
abeldantas Sep 16, 2026
7617664
Provision a group of rooms from one template document (CHOO-2657)
abeldantas Sep 16, 2026
79d53f9
One Use page for every template, and templates that make a team (CHOO…
abeldantas Sep 16, 2026
d3582f9
Let the server accept a provider-typed template param (CHOO-2665)
abeldantas Sep 16, 2026
27259a8
Show a saved built-in template once whatever its kind (CHOO-2665)
abeldantas Sep 16, 2026
b88989f
Make the Use page recover from a failed step, and keep its preview ho…
abeldantas Sep 16, 2026
3694406
Put the creator in the triage pair's room (CHOO-2665)
abeldantas Sep 16, 2026
b40a8e7
Find the creator's account on the bridge an input picks (CHOO-2665)
abeldantas Sep 16, 2026
b7d7999
Give the workroom example a description that reads on a card (CHOO-2665)
abeldantas Sep 16, 2026
01e986f
List saved copies on the workspace, add a two-room example, make an a…
abeldantas Sep 16, 2026
696e15e
Show a bridge input as optional on the template page too (CHOO-2665)
abeldantas Sep 16, 2026
ad75d29
Keep Recently used on the Templates page while filtering (CHOO-2665)
abeldantas Sep 16, 2026
70a1269
Pass the filters into the recents section (CHOO-2665)
abeldantas Sep 16, 2026
3b536c2
Say what the template helpers are for, not what they used to be
abeldantas Sep 16, 2026
973834f
Say plainly what the template helpers do
abeldantas Sep 17, 2026
2cad6b2
Drop the filler qualifiers from the template comments
abeldantas Sep 17, 2026
7a7b23a
Rewrite the template comments to say why, in plain words
abeldantas Sep 17, 2026
29389f5
Comments: data does not speak, people are users or deployers, documen…
abeldantas Sep 17, 2026
1e519de
Tighten comments and names on the template pages
abeldantas Sep 17, 2026
cafddb2
Post the kickoff when an agent creates a room from a template
abeldantas Sep 17, 2026
7f3d950
Ship only the Switch expert as a built-in template
abeldantas Sep 17, 2026
ccd49b3
One way to add an agent on Your Agents
abeldantas Sep 17, 2026
d1b312e
Let a template ask for the first messaging app, agent or room
abeldantas Sep 17, 2026
ec8bcac
Switch expert asks for a messaging app, and new agents live under ~/.…
abeldantas Sep 17, 2026
244774a
Use a template in a few clicks, and show the run while it happens
abeldantas Sep 17, 2026
27e5881
feat(console): capture as template — export, parameterize, save (CHOO…
abeldantas Sep 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions connectors/claude-code-plugin/skills/switch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,7 @@ failure-mode tools are covered in the sections just above.
- `list_room_groups` — the group tree rooms are organised into.
- `get_room_group_detail` — one group's rooms and child groups.
- `create_room_group` — provision a new room group.
- `create_room_from_yaml` — provision a room or group from a YAML template.
- `list_agents` — every agent on the instance, with optional filters.
- `get_agent_detail` — one agent's config, capabilities and sessions.
- `update_agent_detail` — change an agent you own.
Expand Down
1 change: 1 addition & 0 deletions connectors/codex-plugin/skills/switch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,7 @@ failure-mode tools are covered in the sections just above.
- `list_room_groups` — the group tree rooms are organised into.
- `get_room_group_detail` — one group's rooms and child groups.
- `create_room_group` — provision a new room group.
- `create_room_from_yaml` — provision a room or group from a YAML template.
- `list_agents` — every agent on the instance, with optional filters.
- `get_agent_detail` — one agent's config, capabilities and sessions.
- `update_agent_detail` — change an agent you own.
Expand Down
1 change: 1 addition & 0 deletions connectors/opencode-plugin/skills/switch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -791,6 +791,7 @@ failure-mode tools are covered in the sections just above.
- `list_room_groups` — the group tree rooms are organised into.
- `get_room_group_detail` — one group's rooms and child groups.
- `create_room_group` — provision a new room group.
- `create_room_from_yaml` — provision a room or group from a YAML template.
- `list_agents` — every agent on the instance, with optional filters.
- `get_agent_detail` — one agent's config, capabilities and sessions.
- `update_agent_detail` — change an agent you own.
Expand Down
4 changes: 4 additions & 0 deletions console/apps/switch-console-desktop/electron.vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ export default defineConfig({
// Per-instance so two dev builds from different worktrees can run at
// once; the main process follows through ELECTRON_RENDERER_URL.
port: Number(process.env.SWITCH_CONSOLE_RENDERER_PORT) || 3000,
// The bundled Switch expert template is read from `switch-expert/` at
// the repository root, outside this app's directory, so the dev server
// has to be allowed to serve it; the production build inlines it.
fs: { allow: [resolve('../../..')] },
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { basename, join } from 'node:path';
import { load } from 'js-yaml';
import { composeTemplateDocument, serverDocument } from './template-document';

/**
* An agent template is a YAML document with an `agent:` block and, optionally,
* a `room:` and `kickoff:` in the room-template shape. The format is described
* field by field in `switch-expert/template.yaml` at the repository root.
*/
export type AgentTemplateSource = { url: string; label: string | null };

/** Who may address the agent: only its owner, its owner and their agents, or anyone in its rooms. */
export type AgentTemplateAddressing = 'owner' | 'owner-agents' | 'anyone';

export type ParsedAgentTemplate = {
/** The agent name from the template's `name` field. The deployer can change it before creating. */
name: string | null;
/** Null when the template has no `addressing` field; the Console's default applies (only its owner). */
addressing: AgentTemplateAddressing | null;
description: string;
instructions: string;
/** Repository the agent works from. The Console offers to clone it into the
* agent's directory; when that is off or fails, the agent clones it itself. */
repoUrl: string | null;
/** Pages the agent should read. Shown to the deployer; the agent fetches them itself. */
sources: AgentTemplateSource[];
/** The room the agent is put in once it exists, when the template declares one. */
room: { name: string | null; kickoff: string | null } | null;
/** A provider id (`claude`, `codex`, `opencode`) or a `{param}` whose value is one. Null when the template has no `provider` field. */
provider: string | null;
warnings: string[];
};

function parseYaml(yamlText: string): Record<string, unknown> {
let doc: unknown;
try {
doc = load(yamlText);
} catch (e) {
throw new Error(`Invalid YAML: ${e instanceof Error ? e.message : String(e)}`);
}
if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) {
throw new Error('Template must be a YAML mapping');
}
return doc as Record<string, unknown>;
}

export function asRecord(value: unknown): Record<string, unknown> | null {
return value !== null && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}

const ADDRESSING_VALUES: ReadonlySet<string> = new Set(['owner', 'owner-agents', 'anyone']);

export function optionalString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}

export function extractSources(raw: unknown): AgentTemplateSource[] {
if (!Array.isArray(raw)) return [];
return raw.flatMap((entry) => {
if (typeof entry === 'string')
return optionalString(entry) ? [{ url: entry, label: null }] : [];
const record = asRecord(entry);
const url = record ? optionalString(record.url) : null;
if (!url) return [];
return [{ url, label: record ? optionalString(record.label) : null }];
});
}

/**
* Remove a leading YAML front matter block from instructions. Claude Code
* agent files start with one, and the Console writes its own when it
* renders the agent's definition file, so one inside the instructions
* would be written twice.
*/
export function stripFrontMatter(instructions: string): string {
const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(instructions);
return match ? instructions.slice(match[0].length).replace(/^\s*\n/, '') : instructions;
}

/**
* Parse a single-agent template.
*
* `fallbackInstructions` is used when the document has no `instructions:`.
* The bundled Switch expert keeps its instructions in `AGENT.md` next to
* the template, and the Console passes that file's content here.
*/
export function parseAgentTemplate(
yamlText: string,
fallbackInstructions: string | null = null
): ParsedAgentTemplate {
const doc = parseYaml(yamlText);
const agent = asRecord(doc.agent);
if (!agent) {
throw new Error('Template must have an "agent:" block.');
}
const instructions = stripFrontMatter(
typeof agent.instructions === 'string' && agent.instructions.trim().length > 0
? agent.instructions
: (fallbackInstructions ?? '')
);
if (instructions.trim().length === 0) {
throw new Error('The "agent:" block needs "instructions:" — the agent has nothing to go on.');
}

const warnings: string[] = [];
const room = asRecord(doc.room);
const kickoff = optionalString(doc.kickoff);
if (kickoff && !room) {
warnings.push('`kickoff:` needs a `room:` to be posted into; without one it is ignored.');
}
if (typeof agent.kickoff === 'string' || typeof agent.room === 'object') {
warnings.push('`room:` and `kickoff:` belong at the top level, beside `agent:`.');
}

const addressing = optionalString(agent.addressing);
if (addressing !== null && !ADDRESSING_VALUES.has(addressing)) {
warnings.push(
`\`addressing: ${addressing}\` is not one of owner, owner-agents, anyone; the agent will answer only its owner.`
);
}

return {
name: optionalString(agent.name),
addressing: ADDRESSING_VALUES.has(addressing ?? '')
? (addressing as AgentTemplateAddressing)
: null,
description: typeof agent.description === 'string' ? agent.description.trim() : '',
instructions,
repoUrl: optionalString(agent.repo),
sources: extractSources(agent.sources),
room: room ? { name: optionalString(room.name), kickoff } : null,
provider: optionalString(agent.provider),
warnings,
};
}

export function agentTemplateRoomDocument(yamlText: string): string | null {
return serverDocument(yamlText);
}

export function composeAgentTemplateDocument(yamlText: string, instructions: string): string {
return composeTemplateDocument(yamlText, instructions);
}

/** A folder named after the repository, inside `dir`. */
export function cloneDirectory(dir: string, repoUrl: string): string {
const name = basename(repoUrl.replace(/\/+$/, '')).replace(/\.git$/, '');
return join(dir, name || 'repo');
}

/**
* `base`, or the first of `base-2`, `base-3`, … that does not already hold
* an agent (a `.switch/` directory). A directory left behind by a removed
* agent still holds that agent's credentials, and creating an agent refuses
* to overwrite them, so the suggestion moves to a free directory instead of
* failing at creation time.
*/
export async function firstFreeDirectory(
base: string,
isTaken: (dir: string) => Promise<boolean>
): Promise<string> {
let candidate = base;
for (let i = 2; await isTaken(candidate); i++) candidate = `${base}-${i}`;
return candidate;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { load } from 'js-yaml';
import { describe, expect, it } from 'vitest';
import {
agentTemplateRoomDocument,
cloneDirectory,
composeAgentTemplateDocument,
firstFreeDirectory,
parseAgentTemplate,
stripFrontMatter,
} from './agent-template-format';

const SWITCH_EXPERT = `
version: 1
agent:
name: switch-expert
description: Answers questions about Switch.
instructions: |
You are switch-expert.
repo: https://github.com/sandbox-quantum/switch
sources:
- label: Stand up a Switch expert
url: https://docs.flintai.dev/flintai/switch/getting-started/switch-expert
- https://docs.flintai.dev
room:
name: "Ask {agent}"
agents: ["{agent}"]
users: ["{$creator}"]
kickoff: |
@{agent} hi.
`;

describe('parseAgentTemplate', () => {
it('reads the agent block, the repo and the sources', () => {
const t = parseAgentTemplate(SWITCH_EXPERT);
expect(t.name).toBe('switch-expert');
expect(t.description).toBe('Answers questions about Switch.');
expect(t.instructions).toContain('You are switch-expert.');
expect(t.repoUrl).toBe('https://github.com/sandbox-quantum/switch');
expect(t.sources).toEqual([
{
label: 'Stand up a Switch expert',
url: 'https://docs.flintai.dev/flintai/switch/getting-started/switch-expert',
},
{ label: null, url: 'https://docs.flintai.dev' },
]);
expect(t.room).toEqual({ name: 'Ask {agent}', kickoff: '@{agent} hi.' });
expect(t.warnings).toEqual([]);
});

it('is fine without a room, a repo or sources', () => {
const t = parseAgentTemplate('agent:\n description: d\n instructions: i\n');
expect(t.name).toBeNull();
expect(t.repoUrl).toBeNull();
expect(t.sources).toEqual([]);
expect(t.room).toBeNull();
});

it('reads who may address the agent, and warns about a value it does not know', () => {
expect(parseAgentTemplate('agent:\n instructions: i\n addressing: anyone\n').addressing).toBe(
'anyone'
);
expect(parseAgentTemplate('agent:\n instructions: i\n').addressing).toBeNull();
const odd = parseAgentTemplate('agent:\n instructions: i\n addressing: everyone\n');
expect(odd.addressing).toBeNull();
expect(odd.warnings[0]).toMatch(/addressing/);
});

it('refuses a document with no agent block', () => {
expect(() => parseAgentTemplate('room:\n name: r\n')).toThrow(/"agent:" block/);
});

it('refuses an agent with no instructions', () => {
expect(() => parseAgentTemplate('agent:\n name: a\n description: d\n')).toThrow(
/instructions/
);
});

it('refuses text that is not YAML', () => {
expect(() => parseAgentTemplate('agent: [')).toThrow(/Invalid YAML/);
});

it('warns about a kickoff with no room to land in', () => {
const t = parseAgentTemplate('agent:\n instructions: i\nkickoff: hi\n');
expect(t.warnings).toHaveLength(1);
expect(t.warnings[0]).toMatch(/kickoff/);
});
});

describe('stripFrontMatter', () => {
it('drops a leading front matter block and keeps the body', () => {
expect(stripFrontMatter('---\nname: x\ndescription: y\n---\n\nYou are x.\n')).toBe(
'You are x.\n'
);
});

it('leaves instructions without front matter alone', () => {
expect(stripFrontMatter('You are x.\n---\nnot front matter\n')).toBe(
'You are x.\n---\nnot front matter\n'
);
});

it('applies to the fallback instructions too', () => {
const t = parseAgentTemplate('agent:\n description: d\n', '---\nname: a\n---\nBody.\n');
expect(t.instructions).toBe('Body.\n');
});
});

describe('agentTemplateRoomDocument', () => {
it('turns the room half into a room template with the agent as a declared param', () => {
const yaml = agentTemplateRoomDocument(SWITCH_EXPERT);
expect(yaml).not.toBeNull();
const doc = load(yaml!) as Record<string, unknown>;
expect(Object.keys(doc).sort()).toEqual(['kickoff', 'params', 'room', 'version']);
expect((doc.params as Record<string, unknown>).agent).toMatchObject({ type: 'string' });
expect(doc.room).toEqual({
name: 'Ask {agent}',
agents: ['{agent}'],
users: ['{$creator}'],
});
expect(doc.kickoff).toBe('@{agent} hi.\n');
expect(doc).not.toHaveProperty('agent');
});

it('keeps params the template declares itself', () => {
const yaml = agentTemplateRoomDocument(
'agent:\n instructions: i\nparams:\n topic:\n type: string\nroom:\n name: "{topic}"\n'
);
const doc = load(yaml!) as { params: Record<string, unknown> };
expect(Object.keys(doc.params).sort()).toEqual(['agent', 'topic']);
});

it('is null when the template has no room', () => {
expect(agentTemplateRoomDocument('agent:\n instructions: i\n')).toBeNull();
});
});

describe('composeAgentTemplateDocument', () => {
it('inlines the persona, minus its front matter, and keeps the rest of the document', () => {
const out = composeAgentTemplateDocument(
'agent:\n name: a\n repo: https://x/y\nroom:\n name: r\n',
'---\nname: a\n---\nBody.\n'
);
const doc = load(out) as { agent: Record<string, unknown>; room: Record<string, unknown> };
expect(doc.agent.instructions).toBe('Body.\n');
expect(doc.agent.repo).toBe('https://x/y');
expect(doc.room).toEqual({ name: 'r' });
expect(parseAgentTemplate(out).instructions).toBe('Body.\n');
});

it('leaves inline instructions alone', () => {
const out = composeAgentTemplateDocument('agent:\n instructions: mine\n', 'other');
expect((load(out) as { agent: { instructions: string } }).agent.instructions).toBe('mine');
});
});

describe('cloneDirectory', () => {
it('names the clone after the repository', () => {
expect(cloneDirectory('/w/switch-expert', 'https://github.com/sandbox-quantum/switch')).toBe(
'/w/switch-expert/switch'
);
expect(cloneDirectory('/w', 'https://github.com/jqlang/jq.git')).toBe('/w/jq');
expect(cloneDirectory('/w', 'git@github.com:jqlang/jq.git')).toBe('/w/jq');
expect(cloneDirectory('/w', 'https://example.com/repo/')).toBe('/w/repo');
});
});

describe('firstFreeDirectory', () => {
it('keeps the base when nothing lives there, and steps past folders that hold an agent', async () => {
expect(await firstFreeDirectory('/w/a', async () => false)).toBe('/w/a');
const taken = new Set(['/w/a', '/w/a-2']);
expect(await firstFreeDirectory('/w/a', async (d) => taken.has(d))).toBe('/w/a-3');
});
});
Loading
Loading