feat(skills): single-source skill system with markdown SKILL.md + include directive

Skills move out of packages/core/src/application/assistant/skills/*/skill.ts
(TS string constants) into apps/skills/<id>/SKILL.md (Agent Skills spec format
— YAML frontmatter + markdown body). One directory, one loader, one place to
look at every skill the agent can load.

Key change vs the old dev system: a `{{include:<skill-id>}}` directive lets one
skill transclude another. This removes the parallel TS constant for the
knowledge-note style guide — it now lives at apps/skills/knowledge-note-style/
(hidden from catalog) and is pulled into doc-collab + the live-note and
background-task agents via the resolver instead of via a TS import.

Infrastructure:
- packages/core/src/skills/ — types, skill-md-parser, FS-backed official repo,
  SkillResolver with recursive {{include:<id>}} expansion + cycle detection
- packages/shared/src/skill.ts — SkillFrontmatter, SkillCatalogEntry,
  ResolvedSkill schemas
- DI: officialSkillsRepo + skillResolver registered; registerSkillsDir helper
  wires the path before any consumer resolves
- IPC: skills:list / skills:get (read-only) for the Settings UI
- Main: resolveSkillsDir picks Resources/skills (packaged) or repo apps/skills
  (dev). forge.config.cjs ships apps/skills/ as extraResource.

Consumer refactor:
- buildCopilotInstructions: catalog markdown built from resolver.getCatalog()
- builtin-tools: loadSkill uses resolver, new listSkills tool
- background-tasks/agent + live-note/agent: now async builders that load
  the knowledge-note-style skill content via resolver
- runtime.loadAgent: awaits the now-async builders
- Deleted: assistant/skills/ directory, knowledge-note-style.ts

UI:
- New SkillsSettings component (read-only list + detail view) wired into
  Settings dialog as the "Skills" tab.
This commit is contained in:
tusharmagar 2026-05-13 12:31:06 +05:30
parent b01af12148
commit 9a308cb7a9
38 changed files with 1217 additions and 1204 deletions

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: app-navigation
description: >-
Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.
metadata:
title: "App Navigation"
---
# App Navigation Skill
You have access to the **app-navigation** tool which lets you control the Rowboat UI directly — opening notes, switching views, filtering the knowledge base, and creating saved views.
@ -77,6 +84,3 @@ Save the current view configuration as a named base.
- ` + "`open-note`" + ` validates that the file exists before navigating.
- Filter categories and values come from frontmatter in knowledge files.
- **Never send ` + "`columns`" + ` or ` + "`sort`" + ` with ` + "`update-base-view`" + ` unless the user specifically asks to change them.** Only pass the parameters you intend to change — omitted parameters are left untouched.
`;
export default skill;

View file

@ -0,0 +1,136 @@
---
name: background-task
description: >-
Set up a recurring background task — persistent instructions the agent fires on a schedule and/or on matching events (Gmail, Calendar). Either maintains an `index.md` digest (OUTPUT mode) or performs a recurring side-effect like drafting a reply / posting to Slack / calling an API (ACTION mode). Flagship surface for anything recurring.
metadata:
title: "Background Tasks"
---
# Background Tasks Skill
A *background task* is a persistent agent the user configures once and the framework keeps firing — on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at `bg-tasks/<slug>/` and owns two artifacts:
- `task.yaml` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth.
- `index.md` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run.
A task is one of two shapes — the agent decides per run from the verbs in `instructions`:
| Mode | Trigger verbs | Behavior |
|---|---|---|
| **OUTPUT** | "maintain / show / summarize / track / digest" | Rewrite `index.md` to reflect the current state. |
| **ACTION** | "send / draft / post / notify / file / reply / call" | Perform the action, then append a one-line journal entry under `## Journal` in `index.md`. |
Mixed instructions ("summarize and email it") trigger both.
## Tools you'll use (and ones you WON'T)
You have three dedicated builtin tools for this skill:
- `create-background-task` — materializes a new task on disk. **Use this. Do not write `task.yaml` yourself with `workspace-edit`, and do not search the codebase for IPC channels like `bg-task:create`** — they're renderer-side and not callable from here.
- `patch-background-task` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case.
- `run-background-task-agent` — manually fires a task to run now. Always call this immediately after `create-background-task` so the user sees content.
To inspect what tasks already exist, use `workspace-glob` on `bg-tasks/*/task.yaml` and `workspace-readFile` on candidates. The user's bg-tasks folder is workspace-relative.
## Mode: act-first
Bg-task creation is **action-first**. Don't ask "should I?" — read the request, pick a name, call `create-background-task`, then call `run-background-task-agent` with the returned slug. Confirm in one line past-tense at the end. Tell the user the surface name: "Manage it from Background tasks in the sidebar."
The only exception: if a related bg-task already exists, **extend its instructions** via `patch-background-task` rather than creating a duplicate (see "Extend, don't fork").
## When you're loaded
The host's trigger paragraph loads this skill on:
- **Cadence**: "every morning", "daily", "hourly", "each Monday"
- **Watch/monitor**: "watch / monitor / keep an eye on / track / follow X"
- **Recurring artifact**: "morning briefing", "weekly review", "Acme deal dashboard"
- **Event-conditional**: "whenever a relevant email comes in, …"
- **Action verbs**: "draft / reply / call / post / notify / file / brief me on"
- **Decay questions**: "what's the weather", "top HN stories", "latest on X" — answer the one-off, then offer
If the user explicitly says "live note" / "live-note", the host loads the `live-note` skill instead — don't try to handle that case here.
## Workflow
1. **Check for existing tasks.** Before creating, glob `bg-tasks/*/task.yaml` and read any candidates whose intent might overlap with the user's ask. If a related task exists, jump to "Extend, don't fork" below.
2. **Pick a name.** Use a short, friendly title in title-case: "Morning weather", "Q3 deal digest", "HN top stories". The framework slugifies it (lowercase, dashes) for the folder — you don't manage the slug.
3. **Write the instructions.** Capture the user's intent in their own words, with concrete verbs. Bake any specifics (which source, which audience, output shape) into the instructions — the agent re-reads them on every run.
- Good: *"Summarize my unread emails since yesterday 6pm into a one-paragraph digest plus a bulleted list of action items. Skip newsletters and automated notifications."*
- Bad: *"Daily email summary."* (vague — agent will improvise unhelpfully)
4. **Pick triggers.** All three are independently optional; mix freely.
- `cronExpr` — exact times. `"0 7 * * *"` = 7am daily.
- `windows` — time-of-day bands. Each fires once per day inside the band, anywhere — forgiving when the app was offline.
- `eventMatchCriteria` — a natural-language description of which incoming events should wake the task (e.g. "Emails about Q3 OKRs from the leadership team"). Pass-1 routing matches; the agent does Pass-2 before acting.
No triggers at all = manual-only. The user clicks Run.
5. **Call `create-background-task`.** Required: `name`, `instructions`. Optional: `triggers`, `model`, `provider` (leave model/provider unset unless the user explicitly asked). The tool returns a slug.
6. **Call `run-background-task-agent`** with the slug. The agent runs once and populates `index.md`.
7. **Confirm.** One line. Name the task. Point at the sidebar. Done.
## Extend, don't fork
When the user's new ask overlaps with an existing task — e.g. they say "also include X" or the ask is a refinement of an existing task's intent — call `patch-background-task` instead of creating a duplicate.
Signals that you should extend:
- The user says "also …" / "and on top of that …" / "while you're at it …"
- The new ask is a refinement of an existing task's intent (different threshold, additional source, slightly different output)
When extending, pass the full rewritten `instructions` — don't try to surgical-edit a single sentence. The agent rereads instructions every run, so a clean rewrite is fine. After `patch-background-task` returns, call `run-background-task-agent` on the same slug so the user sees the updated output.
## Worked examples
### OUTPUT — morning briefing
User: *"Every morning at 7, give me a one-paragraph summary of overnight news in AI agents."*
1. `create-background-task` with:
- `name`: "AI agent overnight news"
- `instructions`: "Search the web and Hacker News for news about AI agents (autonomous LLM agents, agentic frameworks, agent benchmarks) published in the last 24 hours. Summarize the top developments in one paragraph (3-5 sentences) followed by a 3-5 item bulleted list of the most significant items with a single-sentence note each. Replace the body of index.md."
- `triggers`: { `cronExpr`: "0 7 * * *" }
2. `run-background-task-agent` slug=ai-agent-overnight-news.
3. "Done — created the **AI agent overnight news** task. It'll run every morning at 7 and you can find it in Background tasks in the sidebar."
### ACTION — email auto-reply
User: *"Whenever I get an email about Q3 planning, draft a reply asking when they're free this week."*
1. `create-background-task` with:
- `name`: "Q3 email auto-reply drafts"
- `instructions`: "When an event arrives describing an email thread about Q3 planning, use the Gmail draft-create tool to draft a reply to the latest message asking the sender when they're free for a 30-minute call this week. Do not send the draft — leave it in Drafts for me to review. After drafting, append a journal entry to index.md noting the thread subject and the draft id."
- `triggers`: { `eventMatchCriteria`: "Emails about Q3 planning (roadmap, OKRs, headcount, exec priorities)" }
2. `run-background-task-agent` slug=q3-email-auto-reply-drafts.
3. "Done — created the **Q3 email auto-reply drafts** task. It'll fire on relevant Gmail threads. Manage it from Background tasks in the sidebar."
### ACTION + journal — Slack watcher
User: *"Every weekday morning at 9, post a summary of unresolved high-priority issues to #engineering on Slack."*
1. `create-background-task` with:
- `name`: "Daily eng triage"
- `instructions`: "Each run, query <issue tracker> for unresolved issues labeled priority:high or above. Summarize counts by owner and the three oldest items. Send the summary to #engineering via the Slack tool. After sending, append a journal entry to index.md with the timestamp and the message id."
- `triggers`: { `cronExpr`: "0 9 * * 1-5" }
2. `run-background-task-agent` slug=daily-eng-triage.
## Canonical Schema
```yaml
${schemaYaml}
```
Notes:
- `active` defaults to true. Patch `{ active: false }` to pause without deleting.
- `createdAt` and `lastRun` are runtime-managed — never write them yourself.
- The `triggers` block reuses Live Notes' `Triggers` schema verbatim. Cron grace and 5-minute backoff semantics are identical.
## Exceptions
The `Background tasks` sidebar view has a "New task" button that opens a form-driven flow. If the user is editing fields there or asking about a specific task from that view, *you* are not the right surface — the form is. Point at it ("You can also do this from the New task button in the Background tasks view") and step aside.

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: browser-control
description: >-
Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.
metadata:
title: "Browser Control"
---
# Browser Control Skill
You have access to the **browser-control** tool, which controls Rowboat's embedded browser pane directly.
@ -114,6 +121,3 @@ These skills are written against a Python harness, so treat them as **reference
- Use Rowboat's browser for live interaction. Use web search tools for research where a live session is unnecessary.
- Do not wrap browser URLs or browser pages in ` + "```filepath" + ` blocks. Filepath cards are only for real files on disk, not web pages or browser tabs.
- If you mention a page the browser opened, use plain text for the URL/title instead of trying to create a clickable file card.
`;
export default skill;

View file

@ -1,24 +1,31 @@
export const skill = String.raw`
---
name: builtin-tools
description: >-
Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.
metadata:
title: "Builtin Tools Reference"
---
# Builtin Tools Reference
Load this skill when creating or modifying agents that need access to Rowboat's builtin tools (shell execution, file operations, etc.).
## Available Builtin Tools
Agents can use builtin tools by declaring them in the YAML frontmatter \`tools\` section with \`type: builtin\` and the appropriate \`name\`.
Agents can use builtin tools by declaring them in the YAML frontmatter `tools` section with `type: builtin` and the appropriate `name`.
### executeCommand
**The most powerful and versatile builtin tool** - Execute any bash/shell command and get the output.
**Security note:** Commands are filtered through \`config/security.json\` in the workspace root. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
**Security note:** Commands are filtered through `config/security.json` in the workspace root. Populate this file with allowed command names (array or dictionary entries). Any command not present is blocked and returns exit code 126 so the agent knows it violated the policy.
**Agent tool declaration (YAML frontmatter):**
\`\`\`yaml
```yaml
tools:
bash:
type: builtin
name: executeCommand
\`\`\`
```
**What it can do:**
- Run package managers (npm, pip, apt, brew, cargo, go get, etc.)
@ -49,8 +56,8 @@ tools:
- Full bash shell features are available (variables, loops, conditionals, etc.)
- Tools like jq, yq, awk, sed can parse and transform data
**Example agent with executeCommand** (\`agents/arxiv-feed-reader.md\`):
\`\`\`markdown
**Example agent with executeCommand** (`agents/arxiv-feed-reader.md`):
```markdown
---
model: gpt-5.1
tools:
@ -64,15 +71,15 @@ Extract latest papers from the arXiv feed and summarize them.
Use curl to fetch the RSS feed, then parse it with yq and jq:
\\\`\\\`\\\`bash
\\`\\`\\`bash
curl -s https://rss.arxiv.org/rss/cs.AI | yq -p=xml -o=json | jq -r '.rss.channel.item[] | select(.title | test("agent"; "i")) | "\\(.title)\\n\\(.link)\\n\\(.description)\\n"'
\\\`\\\`\\\`
\\`\\`\\`
This will give you papers containing 'agent' in the title.
\`\`\`
```
**Another example - System monitoring agent** (\`agents/system-monitor.md\`):
\`\`\`markdown
**Another example - System monitoring agent** (`agents/system-monitor.md`):
```markdown
---
model: gpt-5.1
tools:
@ -89,10 +96,10 @@ Monitor system resources using bash commands:
- Use 'ps aux' for process list
Parse the output and report any issues.
\`\`\`
```
**Another example - Git automation agent** (\`agents/git-helper.md\`):
\`\`\`markdown
**Another example - Git automation agent** (`agents/git-helper.md`):
```markdown
---
model: gpt-5.1
tools:
@ -109,19 +116,19 @@ Help with git operations. Use commands like:
- 'git branch -a' - List branches
Can also run 'git add', 'git commit', 'git push' when instructed.
\`\`\`
```
## Agent-to-Agent Calling
Agents can call other agents as tools to create complex multi-step workflows. This is the core mechanism for building multi-agent systems in the CLI.
**Tool declaration (YAML frontmatter):**
\`\`\`yaml
```yaml
tools:
summariser:
type: agent
name: summariser_agent
\`\`\`
```
**When to use:**
- Breaking complex tasks into specialized sub-agents
@ -135,8 +142,8 @@ tools:
- Results are returned as tool output
- The calling agent can then continue processing or delegate further
**Example - Agent that delegates to a summarizer** (\`agents/paper_analyzer.md\`):
\`\`\`markdown
**Example - Agent that delegates to a summarizer** (`agents/paper_analyzer.md`):
```markdown
---
model: gpt-5.1
tools:
@ -148,7 +155,7 @@ tools:
Pick 2 interesting papers and summarise each using the summariser tool.
Pass the paper URL to the summariser. Don't ask for human input.
\`\`\`
```
**Tips for agent chaining:**
- Make instructions explicit about when to call other agents
@ -158,43 +165,43 @@ Pass the paper URL to the summariser. Don't ask for human input.
## Additional Builtin Tools
While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`.
While `executeCommand` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like `cat`, `echo`, `tee`, etc. through `executeCommand`.
### Copilot-Specific Builtin Tools
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with workspace management and MCP integration:
#### File & Directory Operations
- \`workspace-readdir\` - List directory contents (supports recursive exploration)
- \`workspace-readFile\` - Read file contents
- \`workspace-writeFile\` - Create or update file contents
- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites)
- \`workspace-remove\` - Remove files or directories
- \`workspace-exists\` - Check if a file or directory exists
- \`workspace-stat\` - Get file/directory statistics
- \`workspace-mkdir\` - Create directories
- \`workspace-rename\` - Rename or move files/directories
- \`workspace-copy\` - Copy files
- \`workspace-getRoot\` - Get workspace root directory path
- \`workspace-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
- \`workspace-grep\` - Search file contents using regex, returns matching files and lines
- `workspace-readdir` - List directory contents (supports recursive exploration)
- `workspace-readFile` - Read file contents
- `workspace-writeFile` - Create or update file contents
- `workspace-edit` - Make precise edits by replacing specific text (safer than full rewrites)
- `workspace-remove` - Remove files or directories
- `workspace-exists` - Check if a file or directory exists
- `workspace-stat` - Get file/directory statistics
- `workspace-mkdir` - Create directories
- `workspace-rename` - Rename or move files/directories
- `workspace-copy` - Copy files
- `workspace-getRoot` - Get workspace root directory path
- `workspace-glob` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
- `workspace-grep` - Search file contents using regex, returns matching files and lines
#### Agent Operations
- \`analyzeAgent\` - Read and analyze an agent file structure
- \`loadSkill\` - Load a Rowboat skill definition into context
- `analyzeAgent` - Read and analyze an agent file structure
- `loadSkill` - Load a Rowboat skill definition into context
#### MCP Operations
- \`addMcpServer\` - Add or update an MCP server configuration (with validation)
- \`listMcpServers\` - List all available MCP servers
- \`listMcpTools\` - List all available tools from a specific MCP server
- \`executeMcpTool\` - **Execute a specific MCP tool on behalf of the user**
- `addMcpServer` - Add or update an MCP server configuration (with validation)
- `listMcpServers` - List all available MCP servers
- `listMcpTools` - List all available tools from a specific MCP server
- `executeMcpTool` - **Execute a specific MCP tool on behalf of the user**
#### Using executeMcpTool as Copilot
The \`executeMcpTool\` builtin allows the copilot to directly execute MCP tools without creating an agent. Load the "mcp-integration" skill for complete guidance on discovering and executing MCP tools, including workflows, schema matching, and examples.
The `executeMcpTool` builtin allows the copilot to directly execute MCP tools without creating an agent. Load the "mcp-integration" skill for complete guidance on discovering and executing MCP tools, including workflows, schema matching, and examples.
**When to use executeMcpTool vs creating an agent:**
- Use \`executeMcpTool\` for immediate, one-time tasks
- Use `executeMcpTool` for immediate, one-time tasks
- Create an agent when the user needs repeated use or autonomous operation
- Create an agent for complex multi-step workflows involving multiple tools
@ -211,18 +218,15 @@ The \`executeMcpTool\` builtin allows the copilot to directly execute MCP tools
- **Use builtin executeCommand** when you need: CLI tools, system operations, data processing, git operations, any shell command
- **Use MCP tools** when you need: Web scraping (firecrawl), text-to-speech (elevenlabs), specialized APIs, external service integrations
- **Use agent tools (\`type: agent\`)** when you need: Complex multi-step logic, task delegation, specialized processing that benefits from LLM reasoning
- **Use agent tools (`type: agent`)** when you need: Complex multi-step logic, task delegation, specialized processing that benefits from LLM reasoning
Many tasks can be accomplished with just \`executeCommand\` and common Unix tools - it's incredibly powerful!
Many tasks can be accomplished with just `executeCommand` and common Unix tools - it's incredibly powerful!
## Key Insight: Multi-Agent Workflows
In the CLI, multi-agent workflows are built by:
1. Creating specialized agents as Markdown files in the \`agents/\` directory
2. Creating an orchestrator agent that has other agents in its \`tools\` (YAML frontmatter)
3. Running the orchestrator with \`rowboatx --agent orchestrator_name\`
1. Creating specialized agents as Markdown files in the `agents/` directory
2. Creating an orchestrator agent that has other agents in its `tools` (YAML frontmatter)
3. Running the orchestrator with `rowboatx --agent orchestrator_name`
There are no separate "workflow" files - everything is an agent defined in Markdown!
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: code-with-agents
description: >-
Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.
metadata:
title: "Code with Agents"
---
# Code with Agents Skill
Use this skill when the user asks you to write code, build a project, create scripts, fix bugs, or do any software development task that should be delegated to a coding agent (Claude Code or Codex).
@ -36,11 +43,11 @@ Before running anything, confirm the following with the user:
Once you know the folder and agent, tell the user:
> I'll use [Claude Code / Codex] to [description of the task] in \`[folder]\`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
> I'll use [Claude Code / Codex] to [description of the task] in `[folder]`. Permission requests from the coding agent itself (file writes, command execution, etc.) will be automatically approved once it starts. Wait for the user's confirmation before you execute anything.
### Step 3: Execute with acpx
Use the \`executeCommand\` tool to run the coding agent via acpx. The command format is:
Use the `executeCommand` tool to run the coding agent via acpx. The command format is:
**For Claude Code:**
` + "`" + `
@ -54,7 +61,7 @@ npx acpx@latest --approve-all --cwd <folder> codex exec "<prompt>"
### Critical: flag order
The \`--approve-all\` and \`--cwd\` flags are global flags and MUST come before the agent name (\`claude\` or \`codex\`). This is the correct order:
The `--approve-all` and `--cwd` flags are global flags and MUST come before the agent name (`claude` or `codex`). This is the correct order:
` + "`" + `
npx acpx@latest [global flags] <agent> exec "<prompt>"
@ -82,9 +89,6 @@ When constructing the prompt for the coding agent:
After the command finishes, look for the summary that the coding agent produced at the end of its output and pass that along to the user as-is. Do not rewrite or add to it. Only add your own explanation if the command failed or the exit code is non-zero.
Do NOT use file reference blocks (e.g. \`\`\`file:path/to/file\`\`\`) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
Do NOT use file reference blocks (e.g. ```file:path/to/file```) when mentioning code files — they may not open correctly. Just refer to file paths as plain text.
- If the exit code is 5, it means permissions were denied — this should not happen with \`--approve-all\`, but if it does, let the user know
`;
export default skill;
- If the exit code is 5, it means permissions were denied — this should not happen with `--approve-all`, but if it does, let the user know

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: composio-integration
description: >-
Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.
metadata:
title: "Composio Integration"
---
# Composio Integration
**Load this skill** when the user asks to interact with ANY third-party service — email, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, calendar, etc. This skill provides the complete workflow for discovering, connecting, and executing Composio tools.
@ -16,34 +23,34 @@ export const skill = String.raw`
| Service | Slug |
|---------|------|
| Gmail | \`gmail\` |
| Google Calendar | \`googlecalendar\` |
| Google Sheets | \`googlesheets\` |
| Google Docs | \`googledocs\` |
| Google Drive | \`googledrive\` |
| Slack | \`slack\` |
| GitHub | \`github\` |
| Notion | \`notion\` |
| Linear | \`linear\` |
| Jira | \`jira\` |
| Asana | \`asana\` |
| Trello | \`trello\` |
| HubSpot | \`hubspot\` |
| Salesforce | \`salesforce\` |
| LinkedIn | \`linkedin\` |
| X (Twitter) | \`twitter\` |
| Reddit | \`reddit\` |
| Dropbox | \`dropbox\` |
| OneDrive | \`onedrive\` |
| Microsoft Outlook | \`microsoft_outlook\` |
| Microsoft Teams | \`microsoft_teams\` |
| Calendly | \`calendly\` |
| Cal.com | \`cal\` |
| Intercom | \`intercom\` |
| Zendesk | \`zendesk\` |
| Airtable | \`airtable\` |
| Gmail | `gmail` |
| Google Calendar | `googlecalendar` |
| Google Sheets | `googlesheets` |
| Google Docs | `googledocs` |
| Google Drive | `googledrive` |
| Slack | `slack` |
| GitHub | `github` |
| Notion | `notion` |
| Linear | `linear` |
| Jira | `jira` |
| Asana | `asana` |
| Trello | `trello` |
| HubSpot | `hubspot` |
| Salesforce | `salesforce` |
| LinkedIn | `linkedin` |
| X (Twitter) | `twitter` |
| Reddit | `reddit` |
| Dropbox | `dropbox` |
| OneDrive | `onedrive` |
| Microsoft Outlook | `microsoft_outlook` |
| Microsoft Teams | `microsoft_teams` |
| Calendly | `calendly` |
| Cal.com | `cal` |
| Intercom | `intercom` |
| Zendesk | `zendesk` |
| Airtable | `airtable` |
**IMPORTANT:** Always use these exact slugs. Do NOT guess — e.g., Google Sheets is \`googlesheets\` (no underscore), not \`google_sheets\`.
**IMPORTANT:** Always use these exact slugs. Do NOT guess — e.g., Google Sheets is `googlesheets` (no underscore), not `google_sheets`.
## Critical: Check First, Connect Second
@ -52,10 +59,10 @@ export const skill = String.raw`
**Flow:**
1. Check if the service is in the "Currently connected" list (in the system prompt above)
2. If **connected** → go directly to step 4
3. If **NOT connected** → call \`composio-connect-toolkit\` once, wait for user to authenticate, then continue
4. Call \`composio-search-tools\` with SHORT keyword queries
5. Read the \`inputSchema\` from results — note \`required\` fields
6. Call \`composio-execute-tool\` with slug, toolkit, and all required arguments
3. If **NOT connected** → call `composio-connect-toolkit` once, wait for user to authenticate, then continue
4. Call `composio-search-tools` with SHORT keyword queries
5. Read the `inputSchema` from results — note `required` fields
6. Call `composio-execute-tool` with slug, toolkit, and all required arguments
**NEVER call composio-connect-toolkit for a service that's already connected.** This creates duplicate connect cards in the UI.
@ -74,42 +81,42 @@ If the first search returns 0 results, try a different short query (e.g., "issue
## Passing Arguments
**ALWAYS include the \`arguments\` field** when calling \`composio-execute-tool\`, even if the tool has no required parameters.
**ALWAYS include the `arguments` field** when calling `composio-execute-tool`, even if the tool has no required parameters.
- Read the \`inputSchema\` from search results carefully
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" → \`owner: "rowboatlabs", repo: "rowboat"\`)
- For tools with empty \`properties: {}\`, pass \`arguments: {}\`
- Read the `inputSchema` from search results carefully
- Extract user-provided values into the correct fields (e.g., "rowboatlabs/rowboat" → `owner: "rowboatlabs", repo: "rowboat"`)
- For tools with empty `properties: {}`, pass `arguments: {}`
- For tools with required fields, pass all of them
### Example: GitHub Issues
User says: "Get me the open issues on rowboatlabs/rowboat"
1. \`composio-search-tools({ query: "list issues", toolkitSlug: "github" })\`
→ finds \`GITHUB_ISSUES_LIST_FOR_REPO\` with required: ["owner", "repo"]
2. \`composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })\`
1. `composio-search-tools({ query: "list issues", toolkitSlug: "github" })`
→ finds `GITHUB_ISSUES_LIST_FOR_REPO` with required: ["owner", "repo"]
2. `composio-execute-tool({ toolSlug: "GITHUB_ISSUES_LIST_FOR_REPO", toolkitSlug: "github", arguments: { owner: "rowboatlabs", repo: "rowboat", state: "open", per_page: 100 } })`
### Example: Gmail Fetch
User says: "What's my latest email?"
1. \`composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })\`
→ finds \`GMAIL_FETCH_EMAILS\`
2. \`composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })\`
1. `composio-search-tools({ query: "fetch emails", toolkitSlug: "gmail" })`
→ finds `GMAIL_FETCH_EMAILS`
2. `composio-execute-tool({ toolSlug: "GMAIL_FETCH_EMAILS", toolkitSlug: "gmail", arguments: { user_id: "me", max_results: 5 } })`
### Example: LinkedIn Profile (no-arg tool)
User says: "Get my LinkedIn profile"
1. \`composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })\`
→ finds \`LINKEDIN_GET_MY_INFO\` with properties: {}
2. \`composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })\`
1. `composio-search-tools({ query: "get profile", toolkitSlug: "linkedin" })`
→ finds `LINKEDIN_GET_MY_INFO` with properties: {}
2. `composio-execute-tool({ toolSlug: "LINKEDIN_GET_MY_INFO", toolkitSlug: "linkedin", arguments: {} })`
## Error Recovery
- **If a tool call fails** (missing fields, 500 error): Fix the arguments and retry IMMEDIATELY. Do NOT stop and narrate the error to the user.
- **If search returns 0 results**: Try a different short query. If still 0, the tool may not exist for that service.
- **If a tool requires connection**: Call \`composio-connect-toolkit\` once, then retry after connection.
- **If a tool requires connection**: Call `composio-connect-toolkit` once, then retry after connection.
## Multi-Part Requests
@ -122,6 +129,3 @@ When the user says "connect X and then do Y" — complete BOTH parts in one turn
- **Read-only actions** (fetch, list, get, search): Execute without asking
- **Mutating actions** (send email, create issue, post, delete): Show the user what you're about to do and confirm before executing
- **Connecting a toolkit**: Always safe — just do it when needed
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: create-presentations
description: >-
Create PDF presentations and slide decks from natural language requests using knowledge base context.
metadata:
title: "Create Presentations"
---
# PDF Presentation Skill
## Theme Selection
@ -79,13 +86,13 @@ Map each point to a slide layout from the Available Layout Types below. For a ty
## Workflow
1. Use workspace-readFile to check knowledge/ for relevant context about the company, product, team, etc.
2. Ensure Playwright is installed: \`npm install playwright && npx playwright install chromium\`
2. Ensure Playwright is installed: `npm install playwright && npx playwright install chromium`
3. Use workspace-getRoot to get the workspace root path.
4. Plan the narrative arc and slide outline (see Content Planning above).
5. Use workspace-writeFile to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each).
6. **Perform the Post-Generation Validation (see below). Fix any issues before proceeding.**
7. Use workspace-writeFile to create the conversion script at tmp/convert.js (workspace-relative) — see Playwright Export section.
8. Run it: \`node <WORKSPACE_ROOT>/tmp/convert.js\`
8. Run it: `node <WORKSPACE_ROOT>/tmp/convert.js`
9. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" and note the theme used.
**Critical**: Never show HTML code to the user. Never ask the user to run commands, install packages, or make technical decisions. The entire pipeline from content to PDF must be invisible to the user.
@ -96,17 +103,17 @@ Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT u
After generating the slide HTML, perform ALL of these checks before converting to PDF:
1. **Title overflow check**: For every slide, verify that the title text at its set font-size fits within the slide width (1280px) minus padding (120px total). If \`title_chars × 0.6 × font_size > 1160\`, reduce font-size. Use these max sizes:
1. **Title overflow check**: For every slide, verify that the title text at its set font-size fits within the slide width (1280px) minus padding (120px total). If `title_chars × 0.6 × font_size > 1160`, reduce font-size. Use these max sizes:
- Short titles (1-3 words): 72px max
- Medium titles (4-6 words): 56px max
- Long titles (7+ words): 44px max
Apply \`word-wrap: break-word\` and \`overflow-wrap: break-word\` to all title elements. Never use \`white-space: nowrap\` on titles.
Apply `word-wrap: break-word` and `overflow-wrap: break-word` to all title elements. Never use `white-space: nowrap` on titles.
2. **Content bounds check**: Verify no element extends beyond the 1280x720 slide boundary. Look for: long titles, bullet lists with 6+ items, wide tables, long labels on charts, text that wraps more lines than the available height allows.
3. **Broken visuals check**: Confirm no \`<img>\` tags reference external URLs. All visuals must be CSS, SVG, or emoji only. Never use external images — they will fail in PDF rendering. Use CSS shapes, gradients, SVG, or emoji for all visual elements.
3. **Broken visuals check**: Confirm no `<img>` tags reference external URLs. All visuals must be CSS, SVG, or emoji only. Never use external images — they will fail in PDF rendering. Use CSS shapes, gradients, SVG, or emoji for all visual elements.
4. **Font loading check**: Verify the Google Fonts \`<link>\` tag includes ALL font families used in the CSS. Missing fonts cause fallback rendering and broken typography.
4. **Font loading check**: Verify the Google Fonts `<link>` tag includes ALL font families used in the CSS. Missing fonts cause fallback rendering and broken typography.
5. **Theme consistency check**: Confirm all slides use the same palette — no rogue colors in charts, backgrounds, or text that don't belong to the chosen theme.
@ -117,15 +124,15 @@ After generating the slide HTML, perform ALL of these checks before converting t
These rules prevent rendering issues in PDF. Violating them causes overlapping rectangles and broken layouts.
1. **No layered elements** — Never create separate elements for backgrounds or shadows. Style content elements directly.
2. **No box-shadow** — Use borders instead: \`border: 1px solid #e5e7eb\`
3. **Bullets via CSS only** — Use \`li::before\` pseudo-elements, not separate DOM elements.
4. **Content must fit** — Slides are 1280x720px with 60px padding. Safe content area is 1160x600px. Use \`overflow: hidden\`.
2. **No box-shadow** — Use borders instead: `border: 1px solid #e5e7eb`
3. **Bullets via CSS only** — Use `li::before` pseudo-elements, not separate DOM elements.
4. **Content must fit** — Slides are 1280x720px with 60px padding. Safe content area is 1160x600px. Use `overflow: hidden`.
5. **No footers or headers** — Never add fixed/absolute-positioned footer or header elements to slides. They overlap with content in PDF rendering. If you need a slide number or title, include it as part of the normal content flow.
6. **No external images** — All visuals must be CSS, SVG, or emoji. External image URLs will render as broken white boxes in PDF.
## Required CSS
\`\`\`css
```css
@page { size: 1280px 720px; margin: 0; }
html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
.slide {
@ -137,11 +144,11 @@ html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !
page-break-inside: avoid;
}
.slide:last-child { page-break-after: auto; }
\`\`\`
```
## Playwright Export
\`\`\`javascript
```javascript
// save as tmp/convert.js via workspace-writeFile
const { chromium } = require('playwright');
const path = require('path');
@ -160,9 +167,9 @@ const path = require('path');
await browser.close();
console.log('Done: ~/Desktop/presentation.pdf');
})();
\`\`\`
```
Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by workspace-getRoot.
Replace `<WORKSPACE_ROOT>` with the actual absolute path returned by workspace-getRoot.
## Available Layout Types (35 Templates)
@ -245,7 +252,7 @@ Never use the same layout for consecutive slides. Alternate between dark and lig
### Design Guidelines
- Use Google Fonts loaded via \`<link>\` tag. Recommended pairings:
- Use Google Fonts loaded via `<link>` tag. Recommended pairings:
- **Primary pair**: Outfit (headings) + DM Sans (body) — works for most decks
- **Editorial pair**: Playfair Display (headings) + DM Sans (body) — for reports/proposals
- **Accent fonts**: Space Mono (overlines, labels), Crimson Pro (quotes)
@ -253,7 +260,7 @@ Never use the same layout for consecutive slides. Alternate between dark and lig
- Light slides: use warm neutrals, clean borders, and ample whitespace
- Charts: use CSS (conic-gradient for donuts, inline styles for bar heights) or inline SVG for line/combo charts
- Typography hierarchy: monospace overlines for labels -> sans-serif for headings -> serif for editorial/quotes
- Cards: use \`border-radius: 12-16px\`, subtle borders (\`rgba(255,255,255,0.08)\` on dark), no box-shadow (PDF rule)
- Cards: use `border-radius: 12-16px`, subtle borders (`rgba(255,255,255,0.08)` on dark), no box-shadow (PDF rule)
- All visuals must be CSS, SVG, or emoji — no external images
### HTML Template Examples
@ -2738,7 +2745,3 @@ Never use the same layout for consecutive slides. Alternate between dark and lig
</body>
</html>
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: deletion-guardrails
description: >-
Following the confirmation process before removing workflows or agents and their dependencies.
metadata:
title: "Deletion Guardrails"
---
# Deletion Guardrails
Load this skill when a user asks to delete agents or workflows so you follow the required confirmation steps.
@ -19,6 +26,3 @@ Load this skill when a user asks to delete agents or workflows so you follow the
- Never delete cascaded resources automatically.
- Keep a clear audit trail in your responses describing what was removed.
- If the users instructions are ambiguous, ask clarifying questions before taking action.
`;
export default skill;

View file

@ -1,11 +1,19 @@
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../../lib/knowledge-note-style.js';
---
name: doc-collab
description: >-
Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.
metadata:
title: "Document Collaboration"
---
export const skill = String.raw`
# Document Collaboration Skill
You are an expert document assistant helping the user create, edit, and refine documents in their knowledge base.
` + KNOWLEDGE_NOTE_STYLE_GUIDE + String.raw`
{{include:knowledge-note-style}}
> The writing style above is non-negotiable for any content you author or edit in the knowledge base — even small one-off edits. The user's whole knowledge base is built on it. The rest of this skill covers the *workflow* of collaboration; the style guide above covers the *output*.
@ -54,14 +62,14 @@ You are an expert document assistant helping the user create, edit, and refine d
When the user mentions a document name, search for it using multiple approaches:
1. **Search by name pattern** (handles partial matches, different cases):
\`\`\`
```
workspace-glob({ pattern: "knowledge/**/*[name]*", path: "knowledge/" })
\`\`\`
```
2. **Search by content** (finds docs that mention the topic):
\`\`\`
```
workspace-grep({ pattern: "[name]", path: "knowledge/" })
\`\`\`
```
3. **Try common variations:**
- With/without hyphens: "show-hn" vs "showhn" vs "show hn"
@ -78,19 +86,19 @@ workspace-grep({ pattern: "[name]", path: "knowledge/" })
- Ask: "Which document would you like to work on?"
**Creating new documents:**
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to \`knowledge/Notes/\` unless the user specifies a different folder)
1. Ask simply: "Shall I create [filename]?" (don't ask about location - default to `knowledge/Notes/` unless the user specifies a different folder)
2. Create it with just a title - don't pre-populate with structure or outlines
3. Ask: "What would you like in this?"
\`\`\`
```
workspace-createFile({
path: "knowledge/Notes/[Document Name].md",
content: "# [Document Title]\n\n"
})
\`\`\`
```
**WRONG approach:**
- "Should this be in Projects/ or Topics/?" - don't ask, just use \`knowledge/Notes/\`
- "Should this be in Projects/ or Topics/?" - don't ask, just use `knowledge/Notes/`
- "Here's a proposed outline..." - don't propose, let the user guide
- "I'll create a structure with sections for X, Y, Z" - don't assume structure
@ -123,22 +131,22 @@ workspace-createFile({
### Step 3: Execute Changes
**For edits, use workspace-editFile:**
\`\`\`
```
workspace-editFile({
path: "knowledge/[path].md",
old_string: "[exact text to replace]",
new_string: "[new text]"
})
\`\`\`
```
**For additions at the end:**
\`\`\`
```
workspace-editFile({
path: "knowledge/[path].md",
old_string: "[last line or section]",
new_string: "[last line or section]\n\n[new content]"
})
\`\`\`
```
**For new sections:**
Find the right place in the document structure and insert the new section.
@ -155,16 +163,16 @@ After making changes:
When the user mentions people, companies, or projects:
**Search for relevant notes:**
\`\`\`
```
workspace-grep({ pattern: "[Name]", path: "knowledge/" })
\`\`\`
```
**Read relevant notes:**
\`\`\`
```
workspace-readFile("knowledge/People/[Person].md")
workspace-readFile("knowledge/Organizations/[Company].md")
workspace-readFile("knowledge/Projects/[Project].md")
\`\`\`
```
**Use the context:**
- Reference specific facts, dates, and details
@ -173,12 +181,12 @@ workspace-readFile("knowledge/Projects/[Project].md")
## Document Locations
Documents are stored in \`knowledge/\` within the workspace root, with subfolders:
- \`Notes/\` - **Default location for user notes. Create new notes here unless the user specifies a different folder.**
- \`People/\` - Notes about individuals
- \`Organizations/\` - Notes about companies, teams
- \`Projects/\` - Project documentation
- \`Topics/\` - Subject matter notes
Documents are stored in `knowledge/` within the workspace root, with subfolders:
- `Notes/` - **Default location for user notes. Create new notes here unless the user specifies a different folder.**
- `People/` - Notes about individuals
- `Organizations/` - Notes about companies, teams
- `Projects/` - Project documentation
- `Topics/` - Subject matter notes
## Rich Blocks
@ -186,61 +194,61 @@ Notes support rich block types beyond standard Markdown. Blocks are fenced code
### Image Block
Displays an image with optional alt text and caption.
\`\`\`image
```image
{"src": "https://example.com/photo.png", "alt": "Description", "caption": "Optional caption"}
\`\`\`
- \`src\` (required): URL or relative path to the image
- \`alt\` (optional): Alt text
- \`caption\` (optional): Caption displayed below the image
```
- `src` (required): URL or relative path to the image
- `alt` (optional): Alt text
- `caption` (optional): Caption displayed below the image
### Embed Block
Embeds external content (YouTube videos, Figma designs, tweets, or generic links).
\`\`\`embed
```embed
{"provider": "youtube", "url": "https://www.youtube.com/watch?v=VIDEO_ID", "caption": "Video title"}
\`\`\`
- \`provider\` (required): \`"youtube"\`, \`"figma"\`, \`"tweet"\`, or \`"generic"\`
- \`url\` (required): Full URL to the content
- \`caption\` (optional): Caption displayed below the embed
```
- `provider` (required): `"youtube"`, `"figma"`, `"tweet"`, or `"generic"`
- `url` (required): Full URL to the content
- `caption` (optional): Caption displayed below the embed
- YouTube and Figma render as iframes; tweet renders inline from the tweet URL; generic shows a link card
### Iframe Block
Embeds an arbitrary web page or a locally-served dashboard in the note.
\`\`\`iframe
```iframe
{"url": "http://localhost:3210/sites/example-dashboard/", "title": "Trend Dashboard", "height": 640}
\`\`\`
- \`url\` (required): Full URL to render. Use \`https://\` for remote sites, or \`http://localhost:3210/sites/<slug>/\` for local dashboards
- \`title\` (optional): Title shown above the iframe
- \`height\` (optional): Height in pixels. Good dashboard defaults are 480-800
- \`allow\` (optional): Custom iframe \`allow\` attribute when the page needs extra browser capabilities
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in \`sites/<slug>/\` and use the localhost URL above
```
- `url` (required): Full URL to render. Use `https://` for remote sites, or `http://localhost:3210/sites/<slug>/` for local dashboards
- `title` (optional): Title shown above the iframe
- `height` (optional): Height in pixels. Good dashboard defaults are 480-800
- `allow` (optional): Custom iframe `allow` attribute when the page needs extra browser capabilities
- Remote sites may refuse to render in iframes because of their own CSP / X-Frame-Options headers. When you need a reliable embed, create a local site in `sites/<slug>/` and use the localhost URL above
### Chart Block
Renders a chart from inline data.
\`\`\`chart
```chart
{"chart": "bar", "title": "Q1 Revenue", "data": [{"month": "Jan", "revenue": 50000}, {"month": "Feb", "revenue": 62000}], "x": "month", "y": "revenue"}
\`\`\`
- \`chart\` (required): \`"line"\`, \`"bar"\`, or \`"pie"\`
- \`title\` (optional): Chart title
- \`data\` (optional): Array of objects with the data points
- \`source\` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
- \`x\` (required): Key name for the x-axis / label field
- \`y\` (required): Key name for the y-axis / value field
```
- `chart` (required): `"line"`, `"bar"`, or `"pie"`
- `title` (optional): Chart title
- `data` (optional): Array of objects with the data points
- `source` (optional): Relative path to a JSON file containing the data array (alternative to inline data)
- `x` (required): Key name for the x-axis / label field
- `y` (required): Key name for the y-axis / value field
### Table Block
Renders a styled table from structured data.
\`\`\`table
```table
{"title": "Team", "columns": ["name", "role"], "data": [{"name": "Alice", "role": "Eng"}, {"name": "Bob", "role": "Design"}]}
\`\`\`
- \`columns\` (required): Array of column names (determines display order)
- \`data\` (required): Array of row objects
- \`title\` (optional): Table title
```
- `columns` (required): Array of column names (determines display order)
- `data` (required): Array of row objects
- `title` (optional): Table title
### Block Guidelines
- The JSON must be valid and on a single line (no pretty-printing)
- Insert blocks using \`workspace-editFile\` just like any other content
- Insert blocks using `workspace-editFile` just like any other content
- When the user asks for a chart, table, embed, or live dashboard — use blocks rather than plain Markdown tables or image links
- When editing a note that already contains blocks, preserve them unless the user asks to change them
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`
- For local dashboards and mini apps, put the site files in `sites/<slug>/` and point an `iframe` block at `http://localhost:3210/sites/<slug>/`
## Best Practices
@ -257,9 +265,9 @@ Renders a styled table from structured data.
- Be responsive to feedback
**Wiki-links:**
- Use \`[[Person Name]]\` to link to people
- Use \`[[Organization Name]]\` to link to companies
- Use \`[[Project Name]]\` to link to projects
- Use `[[Person Name]]` to link to people
- Use `[[Organization Name]]` to link to companies
- Use `[[Project Name]]` to link to projects
- Only link to notes that exist or that you'll create
## Example Interactions
@ -279,10 +287,10 @@ Renders a styled table from structured data.
**Approval mode - showing changes first:**
**User:** "Add a section about Acme Corp"
**You:** "I'll add this after the Overview section:
\`\`\`
```
## Partnership with Acme Corp
[content based on knowledge...]
\`\`\`
```
Ok to add?"
**User:** "yes"
**You:** *Makes the edit*
@ -302,6 +310,3 @@ Ok to add?"
- "In the meantime, let me propose some sections..." ❌
- Switching from approval mode to direct mode without asking ❌
- In approval mode: making edits without showing the change first ❌
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: draft-emails
description: >-
Process incoming emails and create draft responses using calendar and knowledge base for context.
metadata:
title: "Draft Emails"
---
# Email Draft Skill
You are helping the user draft email responses. Use their calendar and knowledge base for context.
@ -7,21 +14,21 @@ You are helping the user draft email responses. Use their calendar and knowledge
**BEFORE drafting any email, you MUST look up the person/organization in the knowledge base.**
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path).
- **WRONG:** \`path: ""\` or \`path: "."\`
- **CORRECT:** \`path: "knowledge/"\`
**PATH REQUIREMENT:** Always use `knowledge/` as the path (not empty, not the workspace root, not an absolute path).
- **WRONG:** `path: ""` or `path: "."`
- **CORRECT:** `path: "knowledge/"`
When the user says "draft an email to Monica" or mentions ANY person, organization, project, or topic:
1. **STOP** - Do not draft anything yet
2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`):
\`\`\`
2. **SEARCH** - Look them up in the knowledge base (path MUST be `knowledge/`):
```
workspace-grep({ pattern: "Monica", path: "knowledge/" })
\`\`\`
```
3. **READ** - Read their note to understand who they are:
\`\`\`
```
workspace-readFile("knowledge/People/Monica Smith.md")
\`\`\`
```
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN DRAFT** - Only now draft the email, using this context
@ -43,48 +50,48 @@ When the user says "draft an email to Monica" or mentions ANY person, organizati
## State Management
All state is stored in \`pre-built/email-draft/\`:
All state is stored in `pre-built/email-draft/`:
- \`state.json\` - Tracks processing state:
\`\`\`json
- `state.json` - Tracks processing state:
```json
{
"lastProcessedTimestamp": "2025-01-10T00:00:00Z",
"drafted": ["email_id_1", "email_id_2"],
"ignored": ["spam_id_1", "spam_id_2"]
}
\`\`\`
- \`drafts/\` - Contains draft email files
```
- `drafts/` - Contains draft email files
## Initialization
On first run, check if state exists. If not, create it:
1. Check if \`pre-built/email-draft/state.json\` exists
2. If not, create \`pre-built/email-draft/\` and \`pre-built/email-draft/drafts/\`
3. Initialize \`state.json\` with empty arrays and a timestamp of "1970-01-01T00:00:00Z"
1. Check if `pre-built/email-draft/state.json` exists
2. If not, create `pre-built/email-draft/` and `pre-built/email-draft/drafts/`
3. Initialize `state.json` with empty arrays and a timestamp of "1970-01-01T00:00:00Z"
## Processing Flow
### Step 1: Load State
Read \`pre-built/email-draft/state.json\` to get:
- \`lastProcessedTimestamp\` - Only process emails newer than this
- \`drafted\` - List of email IDs already drafted (skip these)
- \`ignored\` - List of email IDs marked as ignored (skip these)
Read `pre-built/email-draft/state.json` to get:
- `lastProcessedTimestamp` - Only process emails newer than this
- `drafted` - List of email IDs already drafted (skip these)
- `ignored` - List of email IDs marked as ignored (skip these)
### Step 2: Scan for New Emails
List emails in \`gmail_sync/\` folder.
List emails in `gmail_sync/` folder.
For each email file:
1. Extract the email ID from filename (e.g., \`19048cf9c0317981.md\` -> \`19048cf9c0317981\`)
2. Skip if ID is in \`drafted\` or \`ignored\` lists
1. Extract the email ID from filename (e.g., `19048cf9c0317981.md` -> `19048cf9c0317981`)
2. Skip if ID is in `drafted` or `ignored` lists
3. Read the email content
### Step 3: Parse Email
Each email file contains:
\`\`\`markdown
```markdown
# Subject Line
**Thread ID:** <id>
@ -96,7 +103,7 @@ Each email file contains:
**Date:** <date string>
<email body>
\`\`\`
```
Extract:
- Thread ID (this is the email ID)
@ -110,7 +117,7 @@ Extract:
Determine the email type and action:
**IGNORE these (add to \`ignored\` list):**
**IGNORE these (add to `ignored` list):**
- Newsletters (unsubscribe links, "View in browser", bulk sender indicators)
- Marketing emails (promotional language, no-reply senders)
- Automated notifications (GitHub, Jira, Slack, shipping updates)
@ -130,23 +137,23 @@ Before drafting, gather relevant context. **Always check the knowledge base firs
**Knowledge Base Context (REQUIRED):**
First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`):
\`\`\`
First, search for the sender and any mentioned entities (path MUST be `knowledge/`):
```
# Search for the sender by name or email
workspace-grep({ pattern: "sender_name_or_email", path: "knowledge/" })
# List all people to find potential matches
workspace-readdir("knowledge/People")
\`\`\`
```
Then read the relevant notes:
\`\`\`
```
# Read the sender's note
workspace-readFile("knowledge/People/Sender Name.md")
# Read their organization's note
workspace-readFile("knowledge/Organizations/Company Name.md")
\`\`\`
```
Extract from these notes:
- Their role, title, and organization
@ -158,18 +165,18 @@ Extract from these notes:
Use this context to provide informed, personalized responses that demonstrate you remember past interactions.
**Calendar Context** (for scheduling emails):
- Read calendar events from \`calendar_sync/\` folder
- Read calendar events from `calendar_sync/` folder
- Look for events in the relevant time period
- Check for conflicts, availability
### Step 6: Create Draft
For emails that need a response, create a draft file in \`pre-built/email-draft/drafts/\`:
For emails that need a response, create a draft file in `pre-built/email-draft/drafts/`:
**Filename:** \`{email_id}_draft.md\`
**Filename:** `{email_id}_draft.md`
**Content format:**
\`\`\`markdown
```markdown
# Draft Response
**Original Email ID:** {email_id}
@ -197,7 +204,7 @@ Subject: Re: {original_subject}
## Notes
{any notes about why this response was crafted this way}
\`\`\`
```
**Drafting Guidelines:**
- Draft ONE email - do not offer multiple versions or options unless explicitly asked
@ -213,15 +220,15 @@ Subject: Re: {original_subject}
### Step 7: Update State
After processing each email:
1. Add the email ID to either \`drafted\` or \`ignored\` list
2. Update \`lastProcessedTimestamp\` to the current time
3. Write updated state to \`pre-built/email-draft/state.json\`
1. Add the email ID to either `drafted` or `ignored` list
2. Update `lastProcessedTimestamp` to the current time
3. Write updated state to `pre-built/email-draft/state.json`
## Output
After processing all new emails, provide a summary:
\`\`\`
```
## Processing Summary
**Emails Scanned:** X
@ -233,7 +240,7 @@ After processing all new emails, provide a summary:
### Ignored:
- {email_id}: {subject} - {reason for ignoring}
\`\`\`
```
## Error Handling
@ -247,6 +254,3 @@ After processing all new emails, provide a summary:
- The user will review and send drafts manually
- Be conservative with ignore - when in doubt, create a draft
- For ambiguous emails, create a draft with a note explaining the ambiguity
`;
export default skill;

View file

@ -1,14 +1,13 @@
/**
* The canonical writing style for content written into the user's knowledge
* base. Imported by both the `doc-collab` skill (so Copilot picks it up on
* note edits) and the live-note run-agent prompt (so background runs use the
* same rules without having to load the skill on every fire). One source of
* truth, two consumers.
*
* If you change this guide, restart the dev server / rebuild — both consumers
* inline it at module load.
*/
export const KNOWLEDGE_NOTE_STYLE_GUIDE = `# Knowledge-note writing style — terse and scannable
---
name: knowledge-note-style
description: >-
Canonical terse-and-scannable writing style for content authored into the user's knowledge base. Included by doc-collab and the live-note / background-task agents — not invoked directly.
hidden: true
metadata:
title: "Knowledge Note Style Guide"
---
# Knowledge-note writing style — terse and scannable
The user's knowledge base is a place they **scan**, not read. Every note competes for attention against many others. Optimize aggressively for **information density and signal-per-line**. These rules apply whether you're authoring a new note, refreshing a live note, or making a one-off edit — they are not optional.
@ -21,32 +20,32 @@ The user's knowledge base is a place they **scan**, not read. Every note compete
## Tightest shape that fits — pick from this ladder
**1. Single line** when the answer is one fact.
- Weather: \`24°, Cloudy · NE 8mph · 12% PoP\`
- Price: \`BTC: $67,432 (+1.2% 24h)\`
- Time: \`2:30 PM IST\`
- Status: \`✓ All systems operational\` or \`⚠ db: degraded\`
- Weather: `24°, Cloudy · NE 8mph · 12% PoP`
- Price: `BTC: $67,432 (+1.2% 24h)`
- Time: `2:30 PM IST`
- Status: `✓ All systems operational` or `⚠ db: degraded`
**2. Compact table** for 2+ parallel items with the same shape.
\`\`\`
```
| Symbol | Price | Δ24h |
|--------|------:|------:|
| BTC | $67k | +1.2% |
| ETH | $3.2k | 0.8% |
\`\`\`
```
**3. Short bullets** for digests and lists. One line per item, ≤80 chars when possible. Lead with the value, push metadata to the end.
- News: \`- <headline> · <source> · <time>\`
- Tasks: \`- [ ] <task> · <due>\`
- HN: \`- <title> · 842 pts · 312 comments\`
- News: `- <headline> · <source> · <time>`
- Tasks: `- [ ] <task> · <due>`
- HN: `- <title> · 842 pts · 312 comments`
**4. Status line + per-component bullets** when there's a top-level state plus details worth surfacing.
\`\`\`
```
⚠ db degraded
- api: 240ms p95 (vs 80ms baseline)
- db: connection pool saturated
\`\`\`
```
**5. Rich block** (\`table\`, \`chart\`, \`calendar\`, \`email\`, \`mermaid\`, etc.) when the data has a natural visual form. Don't render a calendar or chart in plain markdown when the rich block exists.
**5. Rich block** (`table`, `chart`, `calendar`, `email`, `mermaid`, etc.) when the data has a natural visual form. Don't render a calendar or chart in plain markdown when the rich block exists.
## Hard "no" list
@ -54,20 +53,20 @@ The user's knowledge base is a place they **scan**, not read. Every note compete
- **No decorative adjectives**: "comprehensive", "balanced", "polished", "detailed", "high-quality", "carefully curated". They tell the reader nothing concrete.
- **No framing prose**: skip "Here's the latest update on…", "Below is a summary of…", "I've gathered the following…", "Quick rundown:". Get to the data on the first line.
- **No self-reference**: don't write "I updated this section at X" — the system records timestamps. Don't write "This note refreshes hourly" — the user already knows.
- **No caveats unless the data is genuinely uncertain**: "Note: this is approximate", "As of last refresh", "Subject to change" are noise. If freshness matters, encode it inline: \`BTC: $67,432 (as of 14:05 IST)\`.
- **No caveats unless the data is genuinely uncertain**: "Note: this is approximate", "As of last refresh", "Subject to change" are noise. If freshness matters, encode it inline: `BTC: $67,432 (as of 14:05 IST)`.
- **No preamble** — no "Sure, here's…", "Got it, will do — here's the result." Just the result.
- **No filler headers** — a note whose content is a single fact doesn't need a \`## Summary\` heading. Headings exist to break up content, not announce it.
- **No filler headers** — a note whose content is a single fact doesn't need a `## Summary` heading. Headings exist to break up content, not announce it.
## Bullet rules
- One line per bullet. No nesting beyond 2 levels — if you reach for a third level, it should be a new section or a table.
- **Lead with the value.** "BTC at $67k" not "The current BTC price is approximately $67k".
- Use \`·\` (middle dot) as a separator for related fields when stacking 2+ items inline. \`<headline> · <source> · <time>\` reads better than \`(<source>, <time>)\`.
- Use `·` (middle dot) as a separator for related fields when stacking 2+ items inline. `<headline> · <source> · <time>` reads better than `(<source>, <time>)`.
- Push metadata (time, source, status, score) to the **end** of the bullet, after a separator.
## Table rules
- Use a markdown table (or a \`table\` rich block) for ≥3 parallel items. For 1-2 items, use a single line or two bullets — a 2-row table is overhead with no benefit.
- Use a markdown table (or a `table` rich block) for ≥3 parallel items. For 1-2 items, use a single line or two bullets — a 2-row table is overhead with no benefit.
- Aim for ≤4 columns. More and the reader can't scan it.
- Right-align numeric columns when possible.
- No "Notes" column full of prose; if a row needs annotation, footnote it below the table.
@ -83,35 +82,35 @@ Knowledge notes are entry points, not dead ends. **If the user might want to cli
**Format:** make the **headline** the link — that's what the user reaches for first.
- Preferred: \`- [<headline>](<url>) · <source> · <when>\`
- Acceptable: \`- <headline> · [<source>](<url>) · <when>\` when the headline isn't itself an article (e.g. a one-line insight you derived from the source).
- Preferred: `- [<headline>](<url>) · <source> · <when>`
- Acceptable: `- <headline> · [<source>](<url>) · <when>` when the headline isn't itself an article (e.g. a one-line insight you derived from the source).
If the bullet also carries a short description, the link still goes on the headline:
\`- [<headline>](<url>) · <source> · <when> · <one-line description>\`
`- [<headline>](<url>) · <source> · <when> · <one-line description>`
**Not required:**
- Items pulled from the user's own data (calendar events, sent emails, meeting notes the user authored) — the natural reference (event id, sender name, meeting filename) is enough.
- Pure point-in-time facts the user wouldn't drill into ("BTC: $67,432", "24°, Cloudy", "✓ All systems operational"). No link.
**Internal references:** use \`[[Note Name]]\` to link other knowledge-base notes. The editor renders these as clickable wiki-links — preferable to a flat path string.
**Internal references:** use `[[Note Name]]` to link other knowledge-base notes. The editor renders these as clickable wiki-links — preferable to a flat path string.
**When you don't have a URL but it would be useful:** drop the link, keep the source name. Don't fabricate URLs. Don't write \`(link unavailable)\` — that's noise. If the source is a known publication, the source name alone is still informative.
**When you don't have a URL but it would be useful:** drop the link, keep the source name. Don't fabricate URLs. Don't write `(link unavailable)` — that's noise. If the source is a known publication, the source name alone is still informative.
## Genres cookbook
Common note types and the target shape for each:
- **Weather**: single line \`T°, Conditions · Wind · Precip\`. A 3-day micro-forecast as 3 lines if the user asks for it.
- **News digest**: bulleted list. Source attribution + link **required** when you have a URL — see "Sources and links" above. Shape: \`- [<headline>](<url>) · <source> · <date>\` (optionally append \` · <one-line takeaway>\` when the headline alone isn't enough). Group by topic only when >10 items.
- **Stock / crypto prices**: table with \`Symbol | Price | Δ24h | Δ7d\`. Add a \`chart\` block for time series only when the user asks for trends. No links — these are point-in-time facts.
- **Service status**: a single status line; per-component bullets *only* when something is degraded. Link the status page when surfacing the top-level status (\`[✓ All systems operational](<status_url>)\`).
- **Calendar / agenda**: \`calendar\` rich block. Never plain markdown.
- **Email digest**: \`emails\` rich block (multi-thread) or \`email\` block (single thread). Plain markdown only for one-line summaries when there are >20 threads.
- **HN / front-page lists**: bullets — \`- [<title>](<url>) · <points> pts · <comments> comments\`. Title is always the link.
- **Tasks / priorities**: ranked bullets with priority tag — \`- [P0] <task> · <due>\`. \`[[wiki-link]]\` to a source note when one exists (e.g. the task came from a meeting note).
- **Research notes / search results**: bullets with **link**, source, 1-line gist — \`- [<title>](<url>) · <source> · <gist>\`. Link is required when you found this via search. Don't synthesize into prose.
- **GitHub / issue digests**: \`- [<title>](<issue_url>) · <repo> · <state> · <updated>\`.
- **Tweets / social digests**: \`- [<truncated text or topic>](<post_url>) · @<author> · <when>\`.
- **Weather**: single line `T°, Conditions · Wind · Precip`. A 3-day micro-forecast as 3 lines if the user asks for it.
- **News digest**: bulleted list. Source attribution + link **required** when you have a URL — see "Sources and links" above. Shape: `- [<headline>](<url>) · <source> · <date>` (optionally append ` · <one-line takeaway>` when the headline alone isn't enough). Group by topic only when >10 items.
- **Stock / crypto prices**: table with `Symbol | Price | Δ24h | Δ7d`. Add a `chart` block for time series only when the user asks for trends. No links — these are point-in-time facts.
- **Service status**: a single status line; per-component bullets *only* when something is degraded. Link the status page when surfacing the top-level status (`[✓ All systems operational](<status_url>)`).
- **Calendar / agenda**: `calendar` rich block. Never plain markdown.
- **Email digest**: `emails` rich block (multi-thread) or `email` block (single thread). Plain markdown only for one-line summaries when there are >20 threads.
- **HN / front-page lists**: bullets — `- [<title>](<url>) · <points> pts · <comments> comments`. Title is always the link.
- **Tasks / priorities**: ranked bullets with priority tag — `- [P0] <task> · <due>`. `[[wiki-link]]` to a source note when one exists (e.g. the task came from a meeting note).
- **Research notes / search results**: bullets with **link**, source, 1-line gist — `- [<title>](<url>) · <source> · <gist>`. Link is required when you found this via search. Don't synthesize into prose.
- **GitHub / issue digests**: `- [<title>](<issue_url>) · <repo> · <state> · <updated>`.
- **Tweets / social digests**: `- [<truncated text or topic>](<post_url>) · @<author> · <when>`.
## When prose IS appropriate
@ -138,4 +137,3 @@ For everything else: bullets, tables, single lines.
> - [EU passes AI Act amendment on training data](https://www.politico.eu/...) · Politico · 3 PM CET
Same information, ~80% fewer words, scannable in 5 seconds.
`;

View file

@ -1,40 +1,20 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { LiveNoteSchema } from '@x/shared/dist/live-note.js';
---
name: live-note
description: >-
Make a specific markdown note self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule or on incoming events. Load only when the user explicitly says 'live note' / 'live-note'; for anything else recurring, prefer the background-task skill.
metadata:
title: "Live Notes"
---
const schemaYaml = stringifyYaml(z.toJSONSchema(LiveNoteSchema)).trimEnd();
const richBlockMenu = `**5. Rich block render — when the data has a natural visual form.**
The live-note agent can emit *rich blocks* — special fenced blocks the editor renders as styled UI (charts, calendars, embedded iframes, etc.). When the data fits one of these shapes, mention it in the objective so the agent doesn't fall back to plain markdown:
- \`table\` — multi-row data, scoreboards, leaderboards. *"Render the leaderboard as a \`table\` block with columns Rank, Title, Points, Comments."*
- \`chart\` — time series, breakdowns, share-of-total. *"Plot the rate as a \`chart\` block (line, bar, or pie) with x=date, y=rate."*
- \`mermaid\` — flowcharts, sequence/relationship diagrams, gantt charts. *"Render the dependency map as a \`mermaid\` diagram."*
- \`calendar\` — upcoming events / agenda. *"Show the agenda as a \`calendar\` block."*
- \`email\` — single email thread digest (subject, from, summary, latest body, optional draft). *"Render the most important unanswered thread as an \`email\` block."*
- \`image\` — single image with caption. *"Render the cover photo as an \`image\` block."*
- \`embed\` — YouTube or Figma. *"Render the demo as an \`embed\` block."*
- \`iframe\` — live dashboards, status pages, anything that benefits from being live not snapshotted. *"Embed the status page as an \`iframe\` block pointing to <url>."*
- \`transcript\` — long meeting transcripts (collapsible). *"Render the transcript as a \`transcript\` block."*
- \`prompt\` — a "next step" Copilot card the user can click to start a chat. *"End with a \`prompt\` block labeled '<short label>' that runs '<longer prompt to send to Copilot>'."*
You **do not** need to write the block body yourself — describe the desired output inside the objective and the live-note agent will format it (it knows each block's exact schema). Avoid \`task\` block types — those are user-authored input, not agent output.
- Good: "Show today's calendar events. Render as a \`calendar\` block with \`showJoinButton: true\`."
- Good: "Plot USD/INR over the last 7 days as a \`chart\` block — line chart, x=date, y=rate."
- Bad: "Show today's calendar." (vague — agent may produce a markdown bullet list when the user wants the rich block)`;
export const skill = String.raw`
# Live Notes Skill
A *live note* is a regular markdown note whose body is kept current by a background agent. The user expresses intent via a single \`live:\` block in the note's YAML frontmatter — one persistent **objective** plus an optional \`triggers\` object that says when the agent should fire (cron, time-of-day windows, and/or matching events). A note with no \`live:\` key is just static; adding one makes it live. Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor).
A *live note* is a regular markdown note whose body is kept current by a background agent. The user expresses intent via a single `live:` block in the note's YAML frontmatter — one persistent **objective** plus an optional `triggers` object that says when the agent should fire (cron, time-of-day windows, and/or matching events). A note with no `live:` key is just static; adding one makes it live. Users manage live notes in the **Live Note panel** (Radio icon at the top-right of the editor).
When this skill is loaded, your job is: make a passive note live (or extend the objective on an already-live note), run the agent once so the user immediately sees content, and tell them where to manage it.
## Mode: act-first (non-negotiable on strong signals)
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the `live:` block via `workspace-edit`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
What you must NOT do on a strong-signal ask:
- Don't ask "Should I make edits directly, or show changes first for approval?" — that prompt belongs to generic doc editing, not live notes.
@ -68,16 +48,16 @@ When a strong signal lands without a specific note attached, pick the folder by
| Topic shape | Default folder |
|---|---|
| News, headlines, market prices, weather, status pages, reference dashboards | \`knowledge/Notes/\` |
| Tasks, monitors, daily briefings, recurring digests of the user's own data, "background agent"-style work | \`knowledge/Tasks/\` |
| A specific person (e.g. "track everything about Sarah Chen") | \`knowledge/People/\` |
| A specific company / org | \`knowledge/Organizations/\` |
| A specific project or workstream | \`knowledge/Projects/\` |
| A topic / theme | \`knowledge/Topics/\` |
| News, headlines, market prices, weather, status pages, reference dashboards | `knowledge/Notes/` |
| Tasks, monitors, daily briefings, recurring digests of the user's own data, "background agent"-style work | `knowledge/Tasks/` |
| A specific person (e.g. "track everything about Sarah Chen") | `knowledge/People/` |
| A specific company / org | `knowledge/Organizations/` |
| A specific project or workstream | `knowledge/Projects/` |
| A topic / theme | `knowledge/Topics/` |
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
**Filename**: derive from the topic in title-case (`News Feed.md`, `Coinbase News.md`, `SFO Weather.md`).
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
**Before creating**: `workspace-grep` and `workspace-glob` the chosen folder for an existing note that already covers the topic. If one exists with a `live:` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a `live:` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
### Default cadence picker (when the user didn't specify timing)
@ -85,14 +65,14 @@ When the user names a topic but doesn't say *how often*, **pick a cadence** —
| Topic shape | Default cadence |
|---|---|
| News / market summary / topic-following / weather / status | One morning **window** \`06:00\`\`12:00\`. Add an \`eventMatchCriteria\` when the topic could also surface in synced Gmail/Calendar. |
| Stock / crypto prices when the user says "real-time" or "throughout the day" | \`cronExpr\` hourly or every 15 min, depending on phrasing. |
| News / market summary / topic-following / weather / status | One morning **window** `06:00``12:00`. Add an `eventMatchCriteria` when the topic could also surface in synced Gmail/Calendar. |
| Stock / crypto prices when the user says "real-time" or "throughout the day" | `cronExpr` hourly or every 15 min, depending on phrasing. |
| Daily briefings / dashboards | Two or three **windows** spanning the workday (morning, midday, post-lunch). |
| Email / calendar-driven topics (Q3 emails, customer reschedules) | \`eventMatchCriteria\` only — schedule is "when a relevant signal arrives". Add a single morning window if a fallback baseline refresh feels right. |
| Email / calendar-driven topics (Q3 emails, customer reschedules) | `eventMatchCriteria` only — schedule is "when a relevant signal arrives". Add a single morning window if a fallback baseline refresh feels right. |
**When in doubt, default to a single morning window \`06:00\`\`12:00\`.** It's forgiving (fires whenever the user opens the app in the morning) and matches the casual "I'll check this in the morning" expectation.
**When in doubt, default to a single morning window `06:00``12:00`.** It's forgiving (fires whenever the user opens the app in the morning) and matches the casual "I'll check this in the morning" expectation.
Reach for a precise \`cronExpr\` only when the user explicitly demands a clock time ("at 9am sharp", "every 15 minutes"). Casual asks ("every morning", "daily") get windows.
Reach for a precise `cronExpr` only when the user explicitly demands a clock time ("at 9am sharp", "every 15 minutes"). Casual asks ("every morning", "daily") get windows.
### When to ask one short question
@ -114,13 +94,13 @@ Answer the user's actual question first. Then add a single-line offer to keep it
- **Recurring artifacts**: "I'm starting a weekly review note", "my morning briefing", "a dashboard for the Acme deal"
- **Topic-following / catch-up**: "catch me up on the migration project", "I want to follow Project Apollo"
**Catch-all heuristic:** if you reached for \`web-search\` or a news tool to answer a question about a person, company, project, or topic, the answer is exactly the kind of thing a live note would refresh on a schedule — **always offer** at the end. Same goes for any time-decaying lookup (prices, weather, status).
**Catch-all heuristic:** if you reached for `web-search` or a news tool to answer a question about a person, company, project, or topic, the answer is exactly the kind of thing a live note would refresh on a schedule — **always offer** at the end. Same goes for any time-decaying lookup (prices, weather, status).
Offer line shape (one line, concrete):
> "Want me to keep this in a live note that refreshes every morning?"
Or, when there's a sensible default file already implied (e.g. a topic name):
> "I can drop this in \`knowledge/Notes/Coinbase News.md\` and refresh it every morning — want that?"
> "I can drop this in `knowledge/Notes/Coinbase News.md` and refresh it every morning — want that?"
The offer goes at the **very end** of your response, on its own line, after the answer is fully delivered.
@ -133,9 +113,9 @@ The offer goes at the **very end** of your response, on its own line, after the
## Already-live notes — extend, don't fork
**This is the most important rule of the skill.** When the user asks you to track something *new* in a note that **already has a \`live:\` block**, edit the existing \`objective\` in natural language to absorb the new ask. Do **not** create a second \`live:\` block. Do **not** introduce some other key. There is exactly one objective per note.
**This is the most important rule of the skill.** When the user asks you to track something *new* in a note that **already has a `live:` block**, edit the existing `objective` in natural language to absorb the new ask. Do **not** create a second `live:` block. Do **not** introduce some other key. There is exactly one objective per note.
- The user says "also keep an eye on Hacker News stories about this" → read the current \`objective\`, append/integrate the new ask in natural-language prose, write it back.
- The user says "also keep an eye on Hacker News stories about this" → read the current `objective`, append/integrate the new ask in natural-language prose, write it back.
- The objective ends up longer over time. That's fine. The agent treats it as one coherent intent.
- If the new ask conflicts with the old (e.g. user wants to *replace* what the note tracks), ask one short question to confirm before overwriting.
@ -146,7 +126,7 @@ The user knows the feature as **live notes** and finds them in the **Live notes
**Use past tense.** All of these messages are sent *after* the action — no future-tense "I'll do this" or "I'm going to set this up". The action already happened.
After making a passive note live (or creating a new live note from scratch):
> Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view (Radio icon in the sidebar).
> Done — created `knowledge/Notes/News Feed.md` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view (Radio icon in the sidebar).
After extending the objective on an already-live note:
> Updated the objective to also cover that. Re-running now so the new output shows up.
@ -165,11 +145,11 @@ When skipping a re-run (because the user said not to or "later"):
**User:** "i want to set up a news feed to track news for India and the world."
**Right behaviour** (one turn):
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
3. No match found → create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
1. `workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })` — search for an existing match.
2. `workspace-grep({ pattern: "news", path: "knowledge/Notes/" })` — broader search to catch variants.
3. No match found → create `knowledge/Notes/News Feed.md` with a sensible `live:` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an `eventMatchCriteria` if news might come from synced data).
4. Call `run-live-note-agent` with a backfill `context` so the body isn't empty.
5. Reply: "Done — created `knowledge/Notes/News Feed.md` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
**Wrong behaviour:** running 2 lookup tools, then surfacing a paragraph saying "That's a live note use case, so the clean setup is a self-updating news note with: India headlines, world headlines, a refresh cadence like every morning. I need one thing to proceed: which note should this live in? If you don't already have one, I'll create knowledge/Notes/News Feed.md and make it live there." The user already gave you everything you need. Act.
@ -230,11 +210,11 @@ live:
# Note body
` + "```" + `
A note has **at most one** \`live:\` block. Each block has exactly one \`objective\`. The objective can be long and cover several sub-topics — the agent reads it holistically. Omit \`triggers\` (or all three trigger fields) for a manual-only live note.
A note has **at most one** `live:` block. Each block has exactly one `objective`. The objective can be long and cover several sub-topics — the agent reads it holistically. Omit `triggers` (or all three trigger fields) for a manual-only live note.
## Canonical Schema
Below is the authoritative schema for a \`live:\` block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
Below is the authoritative schema for a `live:` block (generated at build time from the TypeScript source — never out of date). Use it to validate every field name, type, and constraint before writing YAML:
` + "```" + `yaml
${schemaYaml}
@ -308,30 +288,30 @@ ${richBlockMenu}
**Default behaviour:** one objective serves all triggers — cron, window, event, and manual runs all see the same intent. **Don't reach for per-trigger branching unless the run actually needs to behave differently.**
The agent always receives a \`**Trigger:**\` line in its run message telling it which trigger fired:
- \`Manual run (user-triggered)\` — Run button or Copilot tool.
- \`Scheduled refresh — the cron expression \\\`<expr>\\\` matched\` — exact-time refresh.
- \`Scheduled refresh — fired inside the configured window\` — forgiving once-per-day baseline refresh.
- \`Event match — Pass 1 routing flagged this note\` — comes with the event payload and a Pass 2 decision directive.
The agent always receives a `**Trigger:**` line in its run message telling it which trigger fired:
- `Manual run (user-triggered)` — Run button or Copilot tool.
- `Scheduled refresh — the cron expression \\`<expr>\\` matched` — exact-time refresh.
- `Scheduled refresh — fired inside the configured window` — forgiving once-per-day baseline refresh.
- `Event match — Pass 1 routing flagged this note` — comes with the event payload and a Pass 2 decision directive.
**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window — pull a full snapshot from local data) and a *reactive* update (event — integrate one new signal). The flagship case is the **Today.md emails section**: on a window run it scans \`gmail_sync/\` for everything worth attention; on an event run with an incoming email payload it integrates that one thread into the existing digest without re-listing previously-seen threads. Same objective, two branches.
**When to branch in the objective:** there's a meaningful difference between the work to do on a *baseline* refresh (cron/window — pull a full snapshot from local data) and a *reactive* update (event — integrate one new signal). The flagship case is the **Today.md emails section**: on a window run it scans `gmail_sync/` for everything worth attention; on an event run with an incoming email payload it integrates that one thread into the existing digest without re-listing previously-seen threads. Same objective, two branches.
How to write it — use plain conditional language inside the objective:
\`\`\`yaml
```yaml
live:
objective: |
Maintain a digest of email threads worth attention today, as a single \`emails\` block.
Maintain a digest of email threads worth attention today, as a single `emails` block.
Without an event payload (cron / window / manual runs): scan \`gmail_sync/\` and emit the
Without an event payload (cron / window / manual runs): scan `gmail_sync/` and emit the
full digest from scratch.
With an event payload (event run): integrate the new thread into the existing digest —
add it if new, update its entry if the threadId is already shown — and don't re-list
threads the user has already seen unless their state changed.
\`\`\`
```
Notice: the objective doesn't mention "cron" or "window" by name, just describes the conditions. The agent reads its \`**Trigger:**\` line and matches the right branch.
Notice: the objective doesn't mention "cron" or "window" by name, just describes the conditions. The agent reads its `**Trigger:**` line and matches the right branch.
**Don't branch for stylistic reasons** ("on cron be terse, on event be verbose"). Branching is for *what data to look at* and *whether to do an incremental vs full update*, not for tone.
@ -339,12 +319,12 @@ Notice: the objective doesn't mention "cron" or "window" by name, just describes
- **Decorative adjectives** describing the output: "polished", "clean", "beautiful", "pleasant", "nicely formatted" — they tell the agent nothing concrete.
- **References to past state** without a mechanism to access it ("as before", "same as last time").
- **A second \`live:\` block** when one already exists — extend the existing objective instead.
- **A second `live:` block** when one already exists — extend the existing objective instead.
- **Open-ended prose requests** ("tell me about X", "give me thoughts on X").
## YAML String Style (critical — read before writing the ` + "`" + `objective` + "`" + ` or ` + "`" + `triggers.eventMatchCriteria` + "`" + `)
The two free-form fields — \`objective\` and \`triggers.eventMatchCriteria\` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes \`lastRunAt\`, \`lastRunSummary\`, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the field gets truncated.
The two free-form fields — `objective` and `triggers.eventMatchCriteria` — are where YAML parsing usually breaks. The runner re-emits the full frontmatter every time it writes `lastRunAt`, `lastRunSummary`, etc., and the YAML library may re-flow long plain (unquoted) strings onto multiple lines. Once that happens, any ` + "`" + `:` + "`" + ` **followed by a space** inside the value silently corrupts the entry: YAML interprets the ` + "`" + `:` + "`" + ` as a new key/value separator and the field gets truncated.
### The rule: always use a safe scalar style
@ -385,19 +365,19 @@ Even if the current value looks safe, a future edit may introduce a ` + "`" + `:
### Never-hand-write fields
\`lastRunAt\`, \`lastRunId\`, \`lastRunSummary\` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
`lastRunAt`, `lastRunId`, `lastRunSummary` are owned by the runner. Don't touch them — don't even try to style them. If your edit's ` + "`" + `oldString` + "`" + ` happens to include these, copy them byte-for-byte into ` + "`" + `newString` + "`" + ` unchanged.
## Triggers
The \`triggers\` object has three optional sub-fields. Mix freely; presence of a field is the marker that the note should fire on that channel.
The `triggers` object has three optional sub-fields. Mix freely; presence of a field is the marker that the note should fire on that channel.
- \`cronExpr\` — fires at an exact recurring time (5-field cron string).
- \`windows\` — list of \`{ startTime, endTime }\` bands; the agent fires once per day per window, anywhere inside the band.
- \`eventMatchCriteria\` — natural-language description of which incoming events (emails, calendar changes) should wake the note.
- `cronExpr` — fires at an exact recurring time (5-field cron string).
- `windows` — list of `{ startTime, endTime }` bands; the agent fires once per day per window, anywhere inside the band.
- `eventMatchCriteria` — natural-language description of which incoming events (emails, calendar changes) should wake the note.
Omit ` + "`" + `triggers` + "`" + ` entirely (or omit all three sub-fields) for a **manual-only** live note — the user runs it from the Run button in the panel.
### \`cronExpr\`
### `cronExpr`
` + "```" + `yaml
triggers:
@ -406,7 +386,7 @@ triggers:
Always quote the cron expression — it contains spaces and ` + "`" + `*` + "`" + `.
### \`windows\`
### `windows`
` + "```" + `yaml
triggers:
@ -415,9 +395,9 @@ triggers:
- { startTime: "13:00", endTime: "15:00" }
` + "```" + `
Each window fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at \`startTime\` — once a fire lands at-or-after today's start, that window is done for the day. Use windows when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
Each window fires **at most once per day, anywhere inside the time-of-day band** (24-hour HH:MM, local). The day's cycle is anchored at `startTime` — once a fire lands at-or-after today's start, that window is done for the day. Use windows when the user wants something to happen "in the morning" rather than at an exact clock time. Forgiving by design: if the app isn't open at the band's start, it still fires the moment the user opens it inside the band.
### \`eventMatchCriteria\`
### `eventMatchCriteria`
` + "```" + `yaml
triggers:
@ -426,7 +406,7 @@ triggers:
` + "```" + `
How event triggering works:
1. When a new event arrives, a fast LLM classifier checks each live note's \`eventMatchCriteria\` (and its objective) against the event content.
1. When a new event arrives, a fast LLM classifier checks each live note's `eventMatchCriteria` (and its objective) against the event content.
2. If it might match, the live-note agent receives both the event payload and the existing note body, and decides whether to actually update.
3. If the event isn't truly relevant on closer inspection, the agent skips the update — no fabricated content.
@ -458,19 +438,19 @@ live:
**Reminder:** once you have enough to act, act. Do not pause to ask about edit mode.
### Making a passive note live (no \`live:\` block yet)
### Making a passive note live (no `live:` block yet)
1. \`workspace-readFile({ path })\` — re-read fresh.
1. `workspace-readFile({ path })` — re-read fresh.
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
3. \`workspace-edit\`:
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
3. `workspace-edit`:
- **If the note has frontmatter without a `live:` block**: anchor on the closing `---` of the frontmatter and insert the `live:` block just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (`---\\n` ... `\\n---\\n` followed by the original first line).
### Extending an already-live note
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`.
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
1. `workspace-readFile({ path })` — fetch the current `live.objective`.
2. Edit the `objective` value via `workspace-edit` to absorb the new ask in natural language. Keep the `|` block scalar style.
3. Don't touch other `live:` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit `triggers.cronExpr`).
### Sidebar chat with a specific note
@ -492,7 +472,7 @@ The two flows below are the **only** exceptions to the act-first default. They h
Sometimes the user arrives from the Suggested Topics panel with a prompt like:
- "I am exploring a suggested topic card from the Suggested Topics panel."
- a title, category, description, and target folder such as \`knowledge/Topics/\` or \`knowledge/People/\`
- a title, category, description, and target folder such as `knowledge/Topics/` or `knowledge/People/`
This is a *browse* gesture, not a commit gesture — the user might back out. So:
1. On the first turn, **do not create or modify anything yet**. Briefly explain the live note you can set up and ask for confirmation.
@ -500,7 +480,7 @@ This is a *browse* gesture, not a commit gesture — the user might back out. So
3. Before creating a new note, search the target folder for an existing matching note and update it (extend objective if already live; make it live otherwise).
4. If no matching note exists and the prompt gave you a target folder, create the new note there without bouncing back to ask.
5. Use the card title as the default note title / filename unless a small normalization is clearly needed.
6. Keep the surrounding note scaffolding minimal but useful. The \`live:\` block should be the core of the note.
6. Keep the surrounding note scaffolding minimal but useful. The `live:` block should be the core of the note.
### Exception 2: New-live-note panel flow (panel-driven, no note named)
@ -543,21 +523,21 @@ live:
` + "```" + `
**Rules:**
- \`live:\` is at the top level of the frontmatter, never nested under other keys.
- There is **at most one** \`live:\` block per note.
- `live:` is at the top level of the frontmatter, never nested under other keys.
- There is **at most one** `live:` block per note.
- 2-space YAML indent throughout. No tabs.
- \`triggers:\` is an object, not an array. Each sub-field (\`cronExpr\`, \`windows\`, \`eventMatchCriteria\`) is independently optional. Omit \`triggers\` entirely for manual-only.
- **Always use the literal block scalar (\`|\`)** for \`objective\` and \`eventMatchCriteria\`.
- **Always quote cron expressions** in YAML — they contain spaces and \`*\`.
- `triggers:` is an object, not an array. Each sub-field (`cronExpr`, `windows`, `eventMatchCriteria`) is independently optional. Omit `triggers` entirely for manual-only.
- **Always use the literal block scalar (`|`)** for `objective` and `eventMatchCriteria`.
- **Always quote cron expressions** in YAML — they contain spaces and `*`.
- The note body below the frontmatter can start empty, with a heading, or with whatever scaffolding the user wants. The live-note agent edits the body on its first run.
## After Creating or Editing a Live Note
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the \`run-live-note-agent\` tool — same as the user clicking Run in the panel.
**Run it once.** Always. The only exception is when the user explicitly said *not* to ("don't run yet", "I'll run it later", "no need to run it now"). Use the `run-live-note-agent` tool — same as the user clicking Run in the panel.
Why default-on:
- For event-driven live notes (with \`eventMatchCriteria\`), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
- For notes that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill \`context\` (see below) seeds rich initial content.
- For event-driven live notes (with `eventMatchCriteria`), the body stays empty until the next matching event arrives. Running once gives the user immediate content.
- For notes that pull from existing local data (synced emails, calendar, meeting notes), running with a backfill `context` (see below) seeds rich initial content.
- After an edit, the user expects to see the updated output without an extra round-trip.
Confirm in one line and tell the user where to find it:
@ -569,13 +549,13 @@ For an objective extension on an already-live note:
If you skipped the re-run (user said not to):
> "Updated — I'll let it run on its next trigger."
**Do not** write content into the note body yourself — that's the live-note agent's job, delegated via \`run-live-note-agent\`.
**Do not** write content into the note body yourself — that's the live-note agent's job, delegated via `run-live-note-agent`.
## Using the \`run-live-note-agent\` tool
## Using the `run-live-note-agent` tool
\`run-live-note-agent\` triggers a single run right now. You can pass an optional \`context\` string to bias *this run only* without modifying the objective — the difference between a stock refresh and a smart backfill.
`run-live-note-agent` triggers a single run right now. You can pass an optional `context` string to bias *this run only* without modifying the objective — the difference between a stock refresh and a smart backfill.
### Backfill \`context\` examples
### Backfill `context` examples
- A newly-live note watching Q3 emails → run with:
> context: "Initial backfill — scan ` + "`" + `gmail_sync/` + "`" + ` for emails from the last 90 days about Q3 planning, OKRs, and roadmap, and synthesize the initial summary."
@ -583,40 +563,40 @@ If you skipped the re-run (user said not to):
> context: "Backfill from this week's meeting notes in ` + "`" + `granola_sync/` + "`" + ` and ` + "`" + `fireflies_sync/` + "`" + `."
- Manual refresh after the user mentions a recent change:
> context: "Focus on changes from the last 7 days only."
- Plain refresh (user said "run it now"): **omit \`context\`**. Don't invent it.
- Plain refresh (user said "run it now"): **omit `context`**. Don't invent it.
### Reading the result
The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, error }` + "`" + `:
- \`action: 'replace'\` → body changed. Confirm in one line; optionally cite the first line of \`contentAfter\`.
- \`action: 'no_update'\` → agent decided nothing needed to change. Tell the user briefly; \`summary\` usually explains why.
- \`error: 'Already running'\` → another run is in flight; tell the user to retry shortly.
- Other \`error\` → surface concisely.
- `action: 'replace'` → body changed. Confirm in one line; optionally cite the first line of `contentAfter`.
- `action: 'no_update'` → agent decided nothing needed to change. Tell the user briefly; `summary` usually explains why.
- `error: 'Already running'` → another run is in flight; tell the user to retry shortly.
- Other `error` → surface concisely.
### Don'ts
- **Don't run more than once** per user-facing action — one tool call per turn.
- **Don't pass \`context\`** for a plain refresh — it can mislead the agent.
- **Don't write content into the note body yourself** — always delegate via \`run-live-note-agent\`.
- **Don't pass `context`** for a plain refresh — it can mislead the agent.
- **Don't write content into the note body yourself** — always delegate via `run-live-note-agent`.
## Don'ts
- **Don't create a second \`live:\` block** when one already exists — extend the existing \`objective\`.
- **Don't add \`triggers\`** if the user explicitly wants manual-only.
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
- **Don't create a second `live:` block** when one already exists — extend the existing `objective`.
- **Don't add `triggers`** if the user explicitly wants manual-only.
- **Don't write** `lastRunAt`, `lastRunId`, or `lastRunSummary` — runtime-managed.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor.
- **Don't use `workspace-writeFile`** to rewrite the whole file — always `workspace-edit` with a unique anchor.
## Editing or Removing an Existing Live Note
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar).
**Change the objective:** `workspace-edit` the `objective` value (use `|` block scalar).
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object.
**Change triggers:** `workspace-edit` the relevant sub-field of the `triggers` object.
**Pause without removing:** flip \`active: false\`.
**Pause without removing:** flip `active: false`.
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
**Make passive (remove the `live:` block):** `workspace-edit` with `oldString` = the entire `live:` block (from the `live:` line down to the next top-level key or the closing `---`), `newString` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
## Quick Reference
@ -625,15 +605,12 @@ Minimal template (frontmatter only):
` + "```" + `yaml
live:
objective: |
<objective always use \`|\`, indented 2 spaces>
<objective always use `|`, indented 2 spaces>
active: true
triggers:
cronExpr: "0 * * * *"
` + "```" + `
Top cron expressions: \`"0 * * * *"\` (hourly), \`"0 8 * * *"\` (daily 8am), \`"0 9 * * 1-5"\` (weekdays 9am), \`"*/15 * * * *"\` (every 15m).
Top cron expressions: `"0 * * * *"` (hourly), `"0 8 * * *"` (daily 8am), `"0 9 * * 1-5"` (weekdays 9am), `"*/15 * * * *"` (every 15m).
YAML style reminder: \`objective\` and \`eventMatchCriteria\` are **always** \`|\` block scalars. Never plain. Never leave a plain scalar in place when editing.
`;
export default skill;
YAML style reminder: `objective` and `eventMatchCriteria` are **always** `|` block scalars. Never plain. Never leave a plain scalar in place when editing.

View file

@ -1,19 +1,26 @@
export const skill = String.raw`
---
name: mcp-integration
description: >-
Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.
metadata:
title: "MCP Integration Guidance"
---
# MCP Integration Guidance
**Load this skill proactively** when a user asks for ANY task that might require external capabilities (web search, internet access, APIs, data fetching, time/date, etc.). This skill provides complete guidance on discovering and executing MCP tools.
## CRITICAL: Composio Tools Take Priority Over MCP
**If a Composio toolkit is connected for the service the user wants (GitHub, Gmail, Slack, etc.), use the \`composio-search-tools\` and \`composio-execute-tool\` builtin tools — NOT MCP tools.** Composio integrations are already authenticated and ready to use. Only fall back to MCP tools if the service is NOT available through Composio.
**If a Composio toolkit is connected for the service the user wants (GitHub, Gmail, Slack, etc.), use the `composio-search-tools` and `composio-execute-tool` builtin tools — NOT MCP tools.** Composio integrations are already authenticated and ready to use. Only fall back to MCP tools if the service is NOT available through Composio.
## When to Check MCP Tools
**IMPORTANT**: When a user asks for a task that requires external capabilities AND no Composio toolkit covers it, check MCP tools:
1. **First check**: Call \`listMcpServers\` to see what's available
2. **Then list tools**: Call \`listMcpTools\` on relevant servers
3. **Execute if possible**: Use \`executeMcpTool\` if a tool matches the need
1. **First check**: Call `listMcpServers` to see what's available
2. **Then list tools**: Call `listMcpTools` on relevant servers
3. **Execute if possible**: Use `executeMcpTool` if a tool matches the need
4. **Only then decline**: If no MCP tool can help, explain what's not possible
**DO NOT** immediately say "I can't do that" or "I don't have internet access" without checking MCP tools first!
@ -22,23 +29,23 @@ export const skill = String.raw`
| User Request | Check For | Likely Tool |
|--------------|-----------|-------------|
| "Search the web/internet" | firecrawl, fetch | \`firecrawl_search\` |
| "Scrape this website" | firecrawl | \`firecrawl_scrape\` |
| "Read/write files" | filesystem | \`read_file\`, \`write_file\` |
| "Get current time/date" | time | \`get_current_time\` |
| "Make HTTP request" | fetch | \`fetch\`, \`post\` |
| "Generate audio/speech" | elevenLabs | \`text_to_speech\` |
| "Search the web/internet" | firecrawl, fetch | `firecrawl_search` |
| "Scrape this website" | firecrawl | `firecrawl_scrape` |
| "Read/write files" | filesystem | `read_file`, `write_file` |
| "Get current time/date" | time | `get_current_time` |
| "Make HTTP request" | fetch | `fetch`, `post` |
| "Generate audio/speech" | elevenLabs | `text_to_speech` |
## Key concepts
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in \`config/mcp.json\`.
- Agents reference MCP tools through the \`"tools"\` block by specifying \`type\`, \`name\`, \`description\`, \`mcpServerName\`, and a full \`inputSchema\`.
- Tool schemas can include optional property descriptions; only include \`"required"\` when parameters are mandatory.
- MCP servers expose tools (web scraping, APIs, databases, etc.) declared in `config/mcp.json`.
- Agents reference MCP tools through the `"tools"` block by specifying `type`, `name`, `description`, `mcpServerName`, and a full `inputSchema`.
- Tool schemas can include optional property descriptions; only include `"required"` when parameters are mandatory.
## CRITICAL: Adding MCP Servers
**ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors.
**ALWAYS use the `addMcpServer` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors.
**NEVER manually create or edit \`config/mcp.json\`** using \`workspace-writeFile\` for MCP servers—this bypasses validation and will cause errors.
**NEVER manually create or edit `config/mcp.json`** using `workspace-writeFile` for MCP servers—this bypasses validation and will cause errors.
### MCP Server Configuration Schema
@ -48,15 +55,15 @@ There are TWO types of MCP servers:
For servers that run as local processes (Node.js, Python, etc.):
**Required fields:**
- \`command\`: string (e.g., "npx", "node", "python", "uvx")
- `command`: string (e.g., "npx", "node", "python", "uvx")
**Optional fields:**
- \`args\`: array of strings (command arguments)
- \`env\`: object with string key-value pairs (environment variables)
- \`type\`: "stdio" (optional, inferred from presence of \`command\`)
- `args`: array of strings (command arguments)
- `env`: object with string key-value pairs (environment variables)
- `type`: "stdio" (optional, inferred from presence of `command`)
**Schema:**
\`\`\`json
```json
{
"type": "stdio",
"command": "string (REQUIRED)",
@ -65,17 +72,17 @@ For servers that run as local processes (Node.js, Python, etc.):
"KEY": "value"
}
}
\`\`\`
```
**Valid STDIO examples:**
\`\`\`json
```json
{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/data"]
}
\`\`\`
```
\`\`\`json
```json
{
"command": "python",
"args": ["-m", "mcp_server_git"],
@ -83,27 +90,27 @@ For servers that run as local processes (Node.js, Python, etc.):
"GIT_REPO_PATH": "/path/to/repo"
}
}
\`\`\`
```
\`\`\`json
```json
{
"command": "uvx",
"args": ["mcp-server-fetch"]
}
\`\`\`
```
#### 2. HTTP/SSE Servers
For servers that expose HTTP or Server-Sent Events endpoints:
**Required fields:**
- \`url\`: string (complete URL including protocol and path)
- `url`: string (complete URL including protocol and path)
**Optional fields:**
- \`headers\`: object with string key-value pairs (HTTP headers)
- \`type\`: "http" (optional, inferred from presence of \`url\`)
- `headers`: object with string key-value pairs (HTTP headers)
- `type`: "http" (optional, inferred from presence of `url`)
**Schema:**
\`\`\`json
```json
{
"type": "http",
"url": "string (REQUIRED)",
@ -112,78 +119,78 @@ For servers that expose HTTP or Server-Sent Events endpoints:
"Custom-Header": "value"
}
}
\`\`\`
```
**Valid HTTP examples:**
\`\`\`json
```json
{
"url": "http://localhost:3000/sse"
}
\`\`\`
```
\`\`\`json
```json
{
"url": "https://api.example.com/mcp",
"headers": {
"Authorization": "Bearer sk-1234567890"
}
}
\`\`\`
```
### Common Validation Errors to Avoid
❌ **WRONG - Missing required field:**
\`\`\`json
```json
{
"args": ["some-arg"]
}
\`\`\`
Error: Missing \`command\` for stdio OR \`url\` for http
```
Error: Missing `command` for stdio OR `url` for http
❌ **WRONG - Empty object:**
\`\`\`json
```json
{}
\`\`\`
Error: Must have either \`command\` (stdio) or \`url\` (http)
```
Error: Must have either `command` (stdio) or `url` (http)
❌ **WRONG - Mixed types:**
\`\`\`json
```json
{
"command": "npx",
"url": "http://localhost:3000"
}
\`\`\`
Error: Cannot have both \`command\` and \`url\`
```
Error: Cannot have both `command` and `url`
✅ **CORRECT - Minimal stdio:**
\`\`\`json
```json
{
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-time"]
}
\`\`\`
```
✅ **CORRECT - Minimal http:**
\`\`\`json
```json
{
"url": "http://localhost:3000/sse"
}
\`\`\`
```
### Using addMcpServer Tool
**Example 1: Add stdio server**
\`\`\`json
```json
{
"serverName": "filesystem",
"serverType": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/data"]
}
\`\`\`
```
**Example 2: Add HTTP server**
\`\`\`json
```json
{
"serverName": "custom-api",
"serverType": "http",
@ -192,10 +199,10 @@ Error: Cannot have both \`command\` and \`url\`
"Authorization": "Bearer token123"
}
}
\`\`\`
```
**Example 3: Add Python MCP server**
\`\`\`json
```json
{
"serverName": "github",
"serverType": "stdio",
@ -205,19 +212,19 @@ Error: Cannot have both \`command\` and \`url\`
"GITHUB_TOKEN": "ghp_xxxxx"
}
}
\`\`\`
```
## Operator actions
1. Use \`listMcpServers\` to enumerate configured servers.
2. Use \`addMcpServer\` to add or update MCP server configurations (with validation).
3. Use \`listMcpTools\` for a server to understand the available operations and schemas.
4. Use \`executeMcpTool\` to run MCP tools directly on behalf of the user.
1. Use `listMcpServers` to enumerate configured servers.
2. Use `addMcpServer` to add or update MCP server configurations (with validation).
3. Use `listMcpTools` for a server to understand the available operations and schemas.
4. Use `executeMcpTool` to run MCP tools directly on behalf of the user.
5. Explain which MCP tools match the user's needs before editing agent definitions.
6. When adding a tool to an agent, document what it does and ensure the schema mirrors the MCP definition.
## Executing MCP Tools Directly (Copilot)
As the copilot, you can execute MCP tools directly on behalf of the user using the \`executeMcpTool\` builtin. This allows you to use MCP tools without creating an agent.
As the copilot, you can execute MCP tools directly on behalf of the user using the `executeMcpTool` builtin. This allows you to use MCP tools without creating an agent.
### When to Execute MCP Tools Directly
- User asks you to perform a task that an MCP tool can handle (web search, file operations, API calls, etc.)
@ -226,23 +233,23 @@ As the copilot, you can execute MCP tools directly on behalf of the user using t
- You're helping the user accomplish a one-time task
### Workflow for Executing MCP Tools
1. **Discover available servers**: Use \`listMcpServers\` to see what MCP servers are configured
2. **List tools from a server**: Use \`listMcpTools\` with the server name to see available tools and their schemas
3. **CAREFULLY EXAMINE THE SCHEMA**: Look at the \`inputSchema\` to understand exactly what parameters are required
4. **Execute the tool**: Use \`executeMcpTool\` with the server name, tool name, and required arguments (matching the schema exactly)
1. **Discover available servers**: Use `listMcpServers` to see what MCP servers are configured
2. **List tools from a server**: Use `listMcpTools` with the server name to see available tools and their schemas
3. **CAREFULLY EXAMINE THE SCHEMA**: Look at the `inputSchema` to understand exactly what parameters are required
4. **Execute the tool**: Use `executeMcpTool` with the server name, tool name, and required arguments (matching the schema exactly)
5. **Return results**: Present the results to the user in a helpful format
### CRITICAL: Schema Matching
**ALWAYS** examine the \`inputSchema\` from \`listMcpTools\` before calling \`executeMcpTool\`.
**ALWAYS** examine the `inputSchema` from `listMcpTools` before calling `executeMcpTool`.
The schema tells you:
- What parameters are required (check the \`"required"\` array)
- What parameters are required (check the `"required"` array)
- What type each parameter should be (string, number, boolean, object, array)
- Parameter descriptions and examples
**Example schema from listMcpTools:**
\`\`\`json
```json
{
"name": "firecrawl_search",
"inputSchema": {
@ -260,10 +267,10 @@ The schema tells you:
"required": ["query"]
}
}
\`\`\`
```
**Correct executeMcpTool call:**
\`\`\`json
```json
{
"serverName": "firecrawl",
"toolName": "firecrawl_search",
@ -271,18 +278,18 @@ The schema tells you:
"query": "latest AI news"
}
}
\`\`\`
```
**WRONG - Missing arguments:**
\`\`\`json
```json
{
"serverName": "firecrawl",
"toolName": "firecrawl_search"
}
\`\`\`
```
**WRONG - Wrong parameter name:**
\`\`\`json
```json
{
"serverName": "firecrawl",
"toolName": "firecrawl_search",
@ -290,24 +297,24 @@ The schema tells you:
"search": "latest AI news" // Wrong! Should be "query"
}
}
\`\`\`
```
### Example: Using Firecrawl to Search the Web
**Step 1: List servers**
\`\`\`json
```json
// Call: listMcpServers
// Response: { "servers": [{"name": "firecrawl", "type": "stdio", ...}] }
\`\`\`
```
**Step 2: List tools**
\`\`\`json
```json
// Call: listMcpTools with serverName: "firecrawl"
// Response: { "tools": [{"name": "firecrawl_search", "description": "Search the web", "inputSchema": {...}}] }
\`\`\`
```
**Step 3: Execute the tool**
\`\`\`json
```json
{
"serverName": "firecrawl",
"toolName": "firecrawl_search",
@ -316,12 +323,12 @@ The schema tells you:
"limit": 5
}
}
\`\`\`
```
### Example: Using Filesystem Tool
**Execute a filesystem read operation:**
\`\`\`json
```json
{
"serverName": "filesystem",
"toolName": "read_file",
@ -329,10 +336,10 @@ The schema tells you:
"path": "/path/to/file.txt"
}
}
\`\`\`
```
### Tips for Executing MCP Tools
- Always check the \`inputSchema\` from \`listMcpTools\` to know what arguments are required
- Always check the `inputSchema` from `listMcpTools` to know what arguments are required
- Match argument types exactly (string, number, boolean, object, array)
- Provide helpful context to the user about what the tool is doing
- Handle errors gracefully and suggest alternatives if a tool fails
@ -343,28 +350,28 @@ The schema tells you:
When a user asks for something that might be accomplished with an MCP tool:
1. **Identify the need**: "You want to search the web? Let me check what MCP tools are available..."
2. **List servers**: Call \`listMcpServers\`
3. **Check for relevant tools**: If you find a relevant server (e.g., "firecrawl" for web search), call \`listMcpTools\`
4. **Execute the tool**: Once you find the right tool and understand its schema, call \`executeMcpTool\`
2. **List servers**: Call `listMcpServers`
3. **Check for relevant tools**: If you find a relevant server (e.g., "firecrawl" for web search), call `listMcpTools`
4. **Execute the tool**: Once you find the right tool and understand its schema, call `executeMcpTool`
5. **Present results**: Format and explain the results to the user
### Common MCP Servers and Their Tools
Based on typical configurations, you might find:
- **firecrawl**: Web scraping, search, crawling (\`firecrawl_search\`, \`firecrawl_scrape\`, \`firecrawl_crawl\`)
- **filesystem**: File operations (\`read_file\`, \`write_file\`, \`list_directory\`)
- **github**: GitHub operations (\`create_issue\`, \`create_pr\`, \`search_repositories\`)
- **fetch**: HTTP requests (\`fetch\`, \`post\`)
- **time**: Time/date operations (\`get_current_time\`, \`convert_timezone\`)
- **firecrawl**: Web scraping, search, crawling (`firecrawl_search`, `firecrawl_scrape`, `firecrawl_crawl`)
- **filesystem**: File operations (`read_file`, `write_file`, `list_directory`)
- **github**: GitHub operations (`create_issue`, `create_pr`, `search_repositories`)
- **fetch**: HTTP requests (`fetch`, `post`)
- **time**: Time/date operations (`get_current_time`, `convert_timezone`)
Always use \`listMcpServers\` and \`listMcpTools\` to discover what's actually available rather than assuming.
Always use `listMcpServers` and `listMcpTools` to discover what's actually available rather than assuming.
## Adding MCP Tools to Agents
Once an MCP server is configured, add its tools to agent definitions (Markdown files with YAML frontmatter):
### MCP Tool Format in Agent (YAML frontmatter)
\`\`\`yaml
```yaml
tools:
descriptive_key:
type: mcp
@ -379,17 +386,17 @@ tools:
description: What param1 means
required:
- param1
\`\`\`
```
### Tool Schema Rules
- Use \`listMcpTools\` to get the exact \`inputSchema\` from the server
- Use `listMcpTools` to get the exact `inputSchema` from the server
- Copy the schema exactly as provided by the MCP server
- Only include \`required\` array if parameters are truly mandatory
- Only include `required` array if parameters are truly mandatory
- Add descriptions to help the agent understand parameter usage
### Example snippets to reference
- Firecrawl search (required param):
\`\`\`yaml
```yaml
tools:
search:
type: mcp
@ -407,10 +414,10 @@ tools:
description: Number of results
required:
- query
\`\`\`
```
- ElevenLabs text-to-speech (no required array):
\`\`\`yaml
```yaml
tools:
text_to_speech:
type: mcp
@ -422,15 +429,12 @@ tools:
properties:
text:
type: string
\`\`\`
```
## Safety reminders
- ALWAYS use \`addMcpServer\` to configure MCP servers—never manually edit config files
- Only recommend MCP tools that are actually configured (use \`listMcpServers\` first)
- ALWAYS use `addMcpServer` to configure MCP servers—never manually edit config files
- Only recommend MCP tools that are actually configured (use `listMcpServers` first)
- Clarify any missing details (required parameters, server names) before modifying files
- Test server connection with \`listMcpTools\` after adding a new server
- Test server connection with `listMcpTools` after adding a new server
- Invalid MCP configs prevent agents from starting—validation is critical
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: meeting-prep
description: >-
Prepare for meetings by gathering context about attendees from the knowledge base.
metadata:
title: "Meeting Prep"
---
# Meeting Prep Skill
You are helping the user prepare for meetings by gathering context from their knowledge base and calendar.
@ -7,22 +14,22 @@ You are helping the user prepare for meetings by gathering context from their kn
**BEFORE creating any meeting brief, you MUST look up the attendees in the knowledge base.**
**PATH REQUIREMENT:** Always use \`knowledge/\` as the path (not empty, not the workspace root, not an absolute path).
- **WRONG:** \`path: ""\` or \`path: "."\`
- **CORRECT:** \`path: "knowledge/"\`
**PATH REQUIREMENT:** Always use `knowledge/` as the path (not empty, not the workspace root, not an absolute path).
- **WRONG:** `path: ""` or `path: "."`
- **CORRECT:** `path: "knowledge/"`
When the user asks to prep for a meeting or mentions attendees:
1. **STOP** - Do not create a generic brief
2. **SEARCH** - Look up each attendee in the knowledge base:
\`\`\`
```
workspace-grep({ pattern: "Attendee Name", path: "knowledge/" })
\`\`\`
```
3. **READ** - Read their notes to understand who they are:
\`\`\`
```
workspace-readFile("knowledge/People/Attendee Name.md")
workspace-readFile("knowledge/Organizations/Their Company.md")
\`\`\`
```
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN BRIEF** - Only now create the meeting brief, using this context
@ -46,11 +53,11 @@ When the user asks to prep for a meeting or mentions attendees:
### Step 1: Identify the Meeting
If the user specifies a meeting:
- Look it up in \`calendar_sync/\` folder
- Look it up in `calendar_sync/` folder
- Parse the event details
If the user says "prep me for my next meeting" or similar:
- List upcoming events from \`calendar_sync/\`
- List upcoming events from `calendar_sync/`
- Find the next meeting with external attendees
- Confirm with the user if unclear
@ -64,18 +71,18 @@ Read the calendar event to extract:
### Step 3: Gather Context from Knowledge Base
For each attendee, search the knowledge base (path MUST be \`knowledge/\`):
For each attendee, search the knowledge base (path MUST be `knowledge/`):
**Search People notes:**
\`\`\`
```
workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" })
workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" })
\`\`\`
```
If a person note exists, read it:
\`\`\`
```
workspace-readFile("knowledge/People/Attendee Name.md")
\`\`\`
```
Extract:
- Their role/title
@ -85,21 +92,21 @@ Extract:
- Open items
**Search Organization notes:**
\`\`\`
```
workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" })
\`\`\`
```
**Search Projects:**
\`\`\`
```
workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" })
workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" })
\`\`\`
```
### Step 4: Create Meeting Brief
Create a brief with this format:
\`\`\`markdown
```markdown
📋
Meeting Brief: {Attendee Name}
{Time} today · {Company}
@ -119,10 +126,10 @@ Open Items
Suggested Talking Points
- {Concrete suggestion based on history}
- {Reference relevant entities with [[wiki-links]]}
\`\`\`
```
**Example:**
\`\`\`markdown
```markdown
📋
Meeting Brief: Sarah Chen
2:00 PM today · Horizon Ventures
@ -142,10 +149,10 @@ Open Items
Suggested Talking Points
- Address her question about CAC by channel
- Mention [[TechFlow]] expansion closed ($120K ARR)
\`\`\`
```
**Briefing Guidelines:**
- Use \`[[Name]]\` wiki-link syntax for cross-references to people, projects, orgs
- Use `[[Name]]` wiki-link syntax for cross-references to people, projects, orgs
- Keep "About" section concise - 2-3 sentences max
- History should be reverse chronological (most recent first)
- Limit to 3-5 most relevant history items
@ -160,6 +167,3 @@ Suggested Talking Points
- For meetings with multiple attendees, create sections for each key person
- Prioritize recent interactions (last 30 days) in the history section
- If an attendee has no notes, suggest what you'd want to capture about them
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: notify-user
description: >-
Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.
metadata:
title: "Notify User"
---
# Notify User
Load this skill when you need to send a desktop notification to the user — e.g. after a long-running task completes, when a track detects something noteworthy, or when an agent wants to ping the user with a clickable result.
@ -7,64 +14,61 @@ Load this skill when you need to send a desktop notification to the user — e.g
- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive.
- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit — restraint is on you).
## The tool: \`notify-user\`
## The tool: `notify-user`
Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click.
### Parameters
- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top.
- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines.
- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted:
- **\`https://...\` / \`http://...\`** — opens in the default browser
- **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below)
- **`title`** (optional, defaults to `"Rowboat"`) — bold headline at the top.
- **`message`** (required) — body text. Keep it short — macOS truncates after a couple of lines.
- **`link`** (optional) — URL to open when the user clicks the notification. Two kinds accepted:
- **`https://...` / `http://...`** — opens in the default browser
- **`rowboat://...`** — opens a view inside Rowboat (see deep links below)
- If omitted, clicking the notification focuses the Rowboat app.
### Examples
Plain alert (no link — clicking focuses the app):
\`\`\`json
```json
{
"title": "Backup complete",
"message": "All 142 files synced to iCloud."
}
\`\`\`
```
External link:
\`\`\`json
```json
{
"title": "New email from Monica",
"message": "Re: Q4 planning — needs your input by Friday",
"link": "https://mail.google.com/mail/u/0/#inbox/abc123"
}
\`\`\`
```
Deep link into a Rowboat note:
\`\`\`json
```json
{
"message": "Daily brief is ready",
"link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md"
}
\`\`\`
```
## Deep links: \`rowboat://\`
## Deep links: `rowboat://`
Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters.
Use these as the `link` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters.
| Target | Format | Example |
|---|---|---|
| Open a file | \`rowboat://open?type=file&path=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` |
| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` |
| Knowledge graph | \`rowboat://open?type=graph\` | — |
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
| Open a file | `rowboat://open?type=file&path=<workspace-relative path>` | `rowboat://open?type=file&path=knowledge/People/Acme.md` |
| Open chat | `rowboat://open?type=chat` (optional `&runId=<id>`) | `rowboat://open?type=chat&runId=abc123` |
| Knowledge graph | `rowboat://open?type=graph` | — |
| Background task view | `rowboat://open?type=task&name=<task-name>` | `rowboat://open?type=task&name=daily-brief` |
| Suggested topics | `rowboat://open?type=suggested-topics` | — |
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
The `type=file` path is workspace-relative (the same path you'd pass to `workspace-readFile`).
## Anti-patterns
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
- **Don't repeat what's already on screen.** If the result is already in the chat or in a note the user is viewing, skip the notification.
- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link.
- **Don't dump the result into `message`.** Surface the headline; put the detail behind a deep link or external link.
- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done".
`;
export default skill;

View file

@ -1,4 +1,11 @@
export const skill = String.raw`
---
name: organize-files
description: >-
Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.
metadata:
title: "Organize Files"
---
# Organize Files Skill
You are helping the user organize, tidy up, and find files on their local machine.
@ -15,7 +22,7 @@ You are helping the user organize, tidy up, and find files on their local machin
**Always preview before acting:**
- Show the user what files will be affected BEFORE moving/deleting
- List the proposed changes and ask for confirmation
- **WRONG:** Immediately run \`mv\` commands without showing what will move
- **WRONG:** Immediately run `mv` commands without showing what will move
- **CORRECT:** "I found 23 screenshots on your Desktop. Here's the plan: [list]. Should I proceed?"
**Be conservative with destructive operations:**
@ -24,14 +31,14 @@ You are helping the user organize, tidy up, and find files on their local machin
- When in doubt, ask
**Handle paths safely:**
- Always quote paths to handle spaces: \`"$HOME/My Documents"\`
- Always quote paths to handle spaces: `"$HOME/My Documents"`
- Expand ~ to $HOME in commands
- Use absolute paths when possible
## Finding Files
**By name pattern:**
\`\`\`bash
```bash
# Find all PDFs in Downloads
find ~/Downloads -name "*.pdf" -type f
@ -40,10 +47,10 @@ find ~/Downloads -iname "*AI*" -type f
# Find screenshots (common naming patterns)
find ~/Desktop -name "Screenshot*" -o -name "Screen Shot*"
\`\`\`
```
**By type:**
\`\`\`bash
```bash
# Images
find ~/Desktop -type f \( -name "*.png" -o -name "*.jpg" -o -name "*.jpeg" -o -name "*.gif" -o -name "*.webp" \)
@ -52,46 +59,46 @@ find ~/Desktop -type f \( -name "*.pdf" -o -name "*.doc" -o -name "*.docx" -o -n
# Videos
find ~/Desktop -type f \( -name "*.mp4" -o -name "*.mov" -o -name "*.avi" -o -name "*.mkv" \)
\`\`\`
```
**By date:**
\`\`\`bash
```bash
# Files modified in last 7 days
find ~/Downloads -type f -mtime -7
# Files older than 30 days
find ~/Downloads -type f -mtime +30
\`\`\`
```
**By content (for text/PDF):**
\`\`\`bash
```bash
# Search inside files for text
grep -r "search term" ~/Documents --include="*.txt" --include="*.md"
# For PDFs, use pdfgrep if available, or list and let user check
find ~/Downloads -name "*.pdf" -exec basename {} \;
\`\`\`
```
**Extracting content from documents:**
When users want to read or summarize a document's contents (PDF, Excel, CSV, Word .docx), use the \`parseFile\` builtin tool. It extracts text from binary formats so you can answer questions about them.
- Accepts absolute paths (e.g., \`~/Downloads/report.pdf\`) or workspace-relative paths — no need to copy files first.
- Supported formats: \`.pdf\`, \`.xlsx\`, \`.xls\`, \`.csv\`, \`.docx\`
When users want to read or summarize a document's contents (PDF, Excel, CSV, Word .docx), use the `parseFile` builtin tool. It extracts text from binary formats so you can answer questions about them.
- Accepts absolute paths (e.g., `~/Downloads/report.pdf`) or workspace-relative paths — no need to copy files first.
- Supported formats: `.pdf`, `.xlsx`, `.xls`, `.csv`, `.docx`
For scanned PDFs, images with text, complex layouts, or presentations where local parsing falls short, use the \`LLMParse\` builtin tool instead. It sends the file to the configured LLM as a multimodal attachment and returns well-structured markdown.
- Supports everything \`parseFile\` does plus images (\`.png\`, \`.jpg\`, \`.gif\`, \`.webp\`, \`.svg\`, \`.bmp\`, \`.tiff\`), PowerPoint (\`.pptx\`), HTML, and plain text.
- Also accepts an optional \`prompt\` parameter for custom extraction instructions.
For scanned PDFs, images with text, complex layouts, or presentations where local parsing falls short, use the `LLMParse` builtin tool instead. It sends the file to the configured LLM as a multimodal attachment and returns well-structured markdown.
- Supports everything `parseFile` does plus images (`.png`, `.jpg`, `.gif`, `.webp`, `.svg`, `.bmp`, `.tiff`), PowerPoint (`.pptx`), HTML, and plain text.
- Also accepts an optional `prompt` parameter for custom extraction instructions.
## Organizing Files
**Create destination folder:**
\`\`\`bash
```bash
mkdir -p ~/Desktop/Screenshots
mkdir -p ~/Downloads/PDFs
mkdir -p ~/Documents/Projects/ProjectName
\`\`\`
```
**Move files:**
\`\`\`bash
```bash
# Move specific file
mv ~/Desktop/Screenshot\ 2024-01-15.png ~/Desktop/Screenshots/
@ -100,36 +107,36 @@ find ~/Desktop -name "Screenshot*" -exec mv {} ~/Desktop/Screenshots/ \;
# Safer: move with verbose output
mv -v ~/Desktop/Screenshot*.png ~/Desktop/Screenshots/
\`\`\`
```
**Batch organization pattern:**
\`\`\`bash
```bash
# Create folders by file type
mkdir -p ~/Desktop/{Screenshots,Documents,Images,Videos,Other}
# Move by type (show user the plan first!)
find ~/Desktop -maxdepth 1 -name "*.png" -exec mv -v {} ~/Desktop/Images/ \;
find ~/Desktop -maxdepth 1 -name "*.pdf" -exec mv -v {} ~/Desktop/Documents/ \;
\`\`\`
```
## Common Organization Tasks
### Screenshots on Desktop
1. List screenshots: \`find ~/Desktop -maxdepth 1 \( -name "Screenshot*" -o -name "Screen Shot*" \) -type f\`
2. Count them: add \`| wc -l\`
3. Create folder: \`mkdir -p ~/Desktop/Screenshots\`
1. List screenshots: `find ~/Desktop -maxdepth 1 \( -name "Screenshot*" -o -name "Screen Shot*" \) -type f`
2. Count them: add `| wc -l`
3. Create folder: `mkdir -p ~/Desktop/Screenshots`
4. Show plan and get confirmation
5. Move: \`find ~/Desktop -maxdepth 1 \( -name "Screenshot*" -o -name "Screen Shot*" \) -exec mv -v {} ~/Desktop/Screenshots/ \;\`
5. Move: `find ~/Desktop -maxdepth 1 \( -name "Screenshot*" -o -name "Screen Shot*" \) -exec mv -v {} ~/Desktop/Screenshots/ \;`
### Clean up Downloads
1. Show file type breakdown:
\`\`\`bash
```bash
echo "=== Downloads Summary ==="
echo "PDFs: $(find ~/Downloads -maxdepth 1 -name '*.pdf' | wc -l)"
echo "Images: $(find ~/Downloads -maxdepth 1 \( -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' \) | wc -l)"
echo "DMGs: $(find ~/Downloads -maxdepth 1 -name '*.dmg' | wc -l)"
echo "ZIPs: $(find ~/Downloads -maxdepth 1 -name '*.zip' | wc -l)"
\`\`\`
```
2. Propose organization structure
3. Get confirmation
4. Execute moves
@ -138,12 +145,12 @@ find ~/Desktop -maxdepth 1 -name "*.pdf" -exec mv -v {} ~/Desktop/Documents/ \;
1. Ask clarifying questions if needed (file type, approximate name, when downloaded)
2. Search with appropriate find command
3. Show matches with full paths
4. Offer to open the containing folder: \`open ~/Downloads\` (macOS)
4. Offer to open the containing folder: `open ~/Downloads` (macOS)
## Output Format
When presenting a plan:
\`\`\`
```
📁 Organization Plan: Desktop Cleanup
Found 47 files to organize:
@ -153,10 +160,10 @@ Found 47 files to organize:
- 4 other files (leaving in place)
Should I proceed with this organization?
\`\`\`
```
When reporting results:
\`\`\`
```
✅ Organization Complete
Moved 43 files:
@ -165,16 +172,13 @@ Moved 43 files:
- 8 images to Images/
4 files left in place (mixed types - review manually)
\`\`\`
```
## Safety Rules
1. **Never delete without explicit permission** - even "cleanup" means organize, not delete
2. **Don't touch system folders** - /System, /Library, /Applications, etc.
3. **Don't touch hidden files** - files starting with . unless explicitly asked
4. **Limit depth** - use \`-maxdepth 1\` unless user wants recursive organization
4. **Limit depth** - use `-maxdepth 1` unless user wants recursive organization
5. **Show before doing** - always preview the operation first
6. **Preserve originals when uncertain** - copy instead of move if unsure
`;
export default skill;

View file

@ -14,6 +14,9 @@ module.exports = {
protocols: [
{ name: 'Rowboat', schemes: ['rowboat'] },
],
// Bundles <repo>/apps/skills/ into Resources/skills/ in the packaged app.
// Read at runtime via process.resourcesPath in main.ts (resolveSkillsDir).
extraResource: [path.join(__dirname, '../../../skills')],
extendInfo: {
NSAudioCaptureUsageDescription: 'Rowboat needs access to system audio to transcribe meetings from other apps (Zoom, Meet, etc.)',
},

View file

@ -67,6 +67,7 @@ import {
listTasks,
readRunIds as readTaskRunIds,
} from '@x/core/dist/background-tasks/fileops.js';
import type { ISkillResolver } from '@x/core/dist/skills/resolver.js';
import { browserIpcHandlers } from './browser/ipc.js';
/**
@ -956,6 +957,16 @@ export function setupIpcHandlers() {
const runIds = await readTaskRunIds(args.slug, args.limit);
return { runIds };
},
// Skills handlers (read-only)
'skills:list': async () => {
const resolver = container.resolve<ISkillResolver>('skillResolver');
const skills = await resolver.getCatalog();
return { skills };
},
'skills:get': async (_event, args) => {
const resolver = container.resolve<ISkillResolver>('skillResolver');
return await resolver.resolve(args.id);
},
// Billing handler
'billing:getInfo': async () => {
return await getBillingInfo();

View file

@ -40,7 +40,7 @@ import started from "electron-squirrel-startup";
import { execSync, exec, execFileSync } from "node:child_process";
import { promisify } from "node:util";
import { init as initChromeSync } from "@x/core/dist/knowledge/chrome-extension/server/server.js";
import { registerBrowserControlService, registerNotificationService } from "@x/core/dist/di/container.js";
import { registerBrowserControlService, registerNotificationService, registerSkillsDir } from "@x/core/dist/di/container.js";
import { browserViewManager, BROWSER_PARTITION } from "./browser/view.js";
import { setupBrowserEventForwarding } from "./browser/ipc.js";
import { ElectronBrowserControlService } from "./browser/control-service.js";
@ -57,6 +57,16 @@ const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
function resolveSkillsDir(): string {
if (app.isPackaged) {
// forge.config.cjs ships apps/skills/ as extraResource → Resources/skills/
return path.join(process.resourcesPath, "skills");
}
// Dev: main runs from apps/x/apps/main/.package/dist/main.cjs,
// so the repo's apps/skills lives 5 levels up + over one.
return path.resolve(__dirname, "..", "..", "..", "..", "..", "skills");
}
// run this as early in the main process as possible
if (started) app.quit();
@ -313,6 +323,9 @@ app.whenReady().then(async () => {
registerBrowserControlService(new ElectronBrowserControlService());
registerNotificationService(new ElectronNotificationService());
// Skills ship bundled with the app. Register the source directory before any
// consumer (resolver, IPC handlers, copilot instructions) resolves.
registerSkillsDir(resolveSkillsDir());
setupIpcHandlers();
setupBrowserEventForwarding();

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, Sparkles } from "lucide-react"
import {
Dialog,
@ -24,8 +24,9 @@ import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { SkillsSettings } from "@/components/settings/skills-settings"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging"
type ConfigTab = "account" | "connected-accounts" | "models" | "mcp" | "security" | "appearance" | "tools" | "note-tagging" | "skills"
interface TabConfig {
id: ConfigTab
@ -88,6 +89,12 @@ const tabs: TabConfig[] = [
path: "config/tags.json",
description: "Configure tags for notes and emails",
},
{
id: "skills",
label: "Skills",
icon: Sparkles,
description: "View bundled copilot skills",
},
]
interface SettingsDialogProps {
@ -1715,7 +1722,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "tools" || activeTab === "account" || activeTab === "connected-accounts") ? "overflow-y-auto" : (activeTab === "note-tagging" || activeTab === "skills") ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connected-accounts" ? (
@ -1728,6 +1735,8 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
<NoteTaggingSettings dialogOpen={open} />
) : activeTab === "appearance" ? (
<AppearanceSettings />
) : activeTab === "skills" ? (
<SkillsSettings dialogOpen={open} />
) : activeTab === "tools" ? (
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
) : loading ? (

View file

@ -0,0 +1,118 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { ArrowLeft } from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import type { skill as skillShared } from "@x/shared"
type SkillCatalogEntry = skillShared.SkillCatalogEntry
type ResolvedSkill = skillShared.ResolvedSkill
interface SkillsSettingsProps {
dialogOpen: boolean
}
export function SkillsSettings({ dialogOpen }: SkillsSettingsProps) {
const [skills, setSkills] = useState<SkillCatalogEntry[]>([])
const [loading, setLoading] = useState(true)
const [selectedSkillId, setSelectedSkillId] = useState<string | null>(null)
const [selectedSkill, setSelectedSkill] = useState<ResolvedSkill | null>(null)
const loadSkills = useCallback(async () => {
try {
setLoading(true)
const result = await window.ipc.invoke("skills:list", null)
setSkills(result.skills)
} catch (err) {
console.error("Failed to load skills:", err)
toast.error("Failed to load skills")
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (dialogOpen) loadSkills()
}, [dialogOpen, loadSkills])
const handleSelectSkill = useCallback(async (skillId: string) => {
try {
const skill = await window.ipc.invoke("skills:get", { id: skillId })
if (skill) {
setSelectedSkillId(skillId)
setSelectedSkill(skill)
}
} catch (err) {
console.error("Failed to load skill:", err)
toast.error("Failed to load skill")
}
}, [])
if (loading) {
return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
Loading skills...
</div>
)
}
if (selectedSkillId && selectedSkill) {
return (
<div className="flex flex-col h-full overflow-hidden gap-3">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedSkillId(null)
setSelectedSkill(null)
}}
className="h-8 px-2"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Button>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{selectedSkill.title}</div>
<div className="text-xs text-muted-foreground truncate">{selectedSkill.summary}</div>
</div>
</div>
<div className="flex-1 overflow-y-auto rounded-md border bg-muted/20 p-3">
<pre className="text-xs font-mono whitespace-pre-wrap">{selectedSkill.content}</pre>
</div>
</div>
)
}
return (
<div className="flex flex-col h-full overflow-hidden">
<p className="text-xs text-muted-foreground shrink-0 mb-3">
Skills are read-only guidance bundled with the app. Updates ship with new app releases.
</p>
<div className="flex-1 overflow-y-auto -mx-1 px-1 space-y-1">
{skills.length === 0 ? (
<div className="text-sm text-muted-foreground text-center py-8">
No skills available.
</div>
) : (
skills.map((skill) => (
<button
key={skill.id}
onClick={() => handleSelectSkill(skill.id)}
className={cn(
"w-full text-left p-3 rounded-md border bg-card hover:bg-accent transition-colors",
)}
>
<div className="font-medium text-sm">{skill.title}</div>
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{skill.summary}
</div>
</button>
))
)}
</div>
</div>
)
}

View file

@ -403,11 +403,11 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
}
if (id === "live-note-agent") {
return buildLiveNoteAgent();
return await buildLiveNoteAgent();
}
if (id === "background-task-agent") {
return buildBackgroundTaskAgent();
return await buildBackgroundTaskAgent();
}
if (id === 'note_creation') {

View file

@ -1,8 +1,26 @@
import { skillCatalog, buildSkillCatalog } from "./skills/index.js";
import { getRuntimeContext, getRuntimeContextPrompt } from "./runtime-context.js";
import { composioAccountsRepo } from "../../composio/repo.js";
import { isConfigured as isComposioConfigured } from "../../composio/client.js";
import { CURATED_TOOLKITS } from "@x/shared/dist/composio.js";
import container from "../../di/container.js";
import type { ISkillResolver } from "../../skills/resolver.js";
import type { SkillCatalogEntry } from "@x/shared/dist/skill.js";
function buildSkillCatalogMarkdown(skills: SkillCatalogEntry[]): string {
const sections = skills.map((skill) => [
`## ${skill.title}`,
`- **Skill id:** \`${skill.id}\``,
`- **Use it for:** ${skill.summary}`,
].join("\n"));
return [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the skill id plus a short description of when it helps.",
"",
sections.join("\n\n"),
].join("\n");
}
const runtimeContextPrompt = getRuntimeContextPrompt(getRuntimeContext());
@ -317,33 +335,22 @@ For browser pages, mention the URL in plain text or use the browser-control tool
Never output raw file paths in plain text when they could be wrapped in a filepath block unless the file does not exist yet.`;
}
/** Keep backward-compatible export for any external consumers */
export const CopilotInstructions = buildStaticInstructions(true, skillCatalog);
/**
* Cached Composio instructions. Invalidated by calling invalidateCopilotInstructionsCache().
*/
let cachedInstructions: string | null = null;
/**
* Invalidate the cached instructions so the next buildCopilotInstructions() call
* regenerates the Composio section. Call this after connecting/disconnecting a toolkit.
*/
export function invalidateCopilotInstructionsCache(): void {
cachedInstructions = null;
}
/**
* Build full copilot instructions with dynamic Composio tools section.
* Results are cached and reused until invalidated via invalidateCopilotInstructionsCache().
*/
export async function buildCopilotInstructions(): Promise<string> {
if (cachedInstructions !== null) return cachedInstructions;
const composioEnabled = await isComposioConfigured();
const catalog = composioEnabled
? skillCatalog
: buildSkillCatalog({ excludeIds: ['composio-integration'] });
const baseInstructions = buildStaticInstructions(composioEnabled, catalog);
const resolver = container.resolve<ISkillResolver>("skillResolver");
const allSkills = await resolver.getCatalog();
const filtered = composioEnabled
? allSkills
: allSkills.filter((s) => s.id !== 'composio-integration');
const catalogMarkdown = buildSkillCatalogMarkdown(filtered);
const baseInstructions = buildStaticInstructions(composioEnabled, catalogMarkdown);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt
? baseInstructions + '\n' + composioPrompt

View file

@ -1,138 +0,0 @@
import { z } from 'zod';
import { stringify as stringifyYaml } from 'yaml';
import { BackgroundTaskSchema } from '@x/shared/dist/background-task.js';
const schemaYaml = stringifyYaml(z.toJSONSchema(BackgroundTaskSchema)).trimEnd();
export const skill = String.raw`
# Background Tasks Skill
A *background task* is a persistent agent the user configures once and the framework keeps firing on a schedule, inside time-of-day windows, and/or in response to matching incoming events (Gmail threads, calendar changes). Each task lives at \`bg-tasks/<slug>/\` and owns two artifacts:
- \`task.yaml\` — the spec (the user's **instructions**, triggers, runtime state). You and the user both treat this as the source of truth.
- \`index.md\` — the agent-owned body. The runtime never writes here; the bg-task agent does, each run.
A task is one of two shapes the agent decides per run from the verbs in \`instructions\`:
| Mode | Trigger verbs | Behavior |
|---|---|---|
| **OUTPUT** | "maintain / show / summarize / track / digest" | Rewrite \`index.md\` to reflect the current state. |
| **ACTION** | "send / draft / post / notify / file / reply / call" | Perform the action, then append a one-line journal entry under \`## Journal\` in \`index.md\`. |
Mixed instructions ("summarize and email it") trigger both.
## Tools you'll use (and ones you WON'T)
You have three dedicated builtin tools for this skill:
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`workspace-edit\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
- \`patch-background-task\` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case.
- \`run-background-task-agent\` — manually fires a task to run now. Always call this immediately after \`create-background-task\` so the user sees content.
To inspect what tasks already exist, use \`workspace-glob\` on \`bg-tasks/*/task.yaml\` and \`workspace-readFile\` on candidates. The user's bg-tasks folder is workspace-relative.
## Mode: act-first
Bg-task creation is **action-first**. Don't ask "should I?" read the request, pick a name, call \`create-background-task\`, then call \`run-background-task-agent\` with the returned slug. Confirm in one line past-tense at the end. Tell the user the surface name: "Manage it from Background tasks in the sidebar."
The only exception: if a related bg-task already exists, **extend its instructions** via \`patch-background-task\` rather than creating a duplicate (see "Extend, don't fork").
## When you're loaded
The host's trigger paragraph loads this skill on:
- **Cadence**: "every morning", "daily", "hourly", "each Monday"
- **Watch/monitor**: "watch / monitor / keep an eye on / track / follow X"
- **Recurring artifact**: "morning briefing", "weekly review", "Acme deal dashboard"
- **Event-conditional**: "whenever a relevant email comes in, …"
- **Action verbs**: "draft / reply / call / post / notify / file / brief me on"
- **Decay questions**: "what's the weather", "top HN stories", "latest on X" answer the one-off, then offer
If the user explicitly says "live note" / "live-note", the host loads the \`live-note\` skill instead — don't try to handle that case here.
## Workflow
1. **Check for existing tasks.** Before creating, glob \`bg-tasks/*/task.yaml\` and read any candidates whose intent might overlap with the user's ask. If a related task exists, jump to "Extend, don't fork" below.
2. **Pick a name.** Use a short, friendly title in title-case: "Morning weather", "Q3 deal digest", "HN top stories". The framework slugifies it (lowercase, dashes) for the folder you don't manage the slug.
3. **Write the instructions.** Capture the user's intent in their own words, with concrete verbs. Bake any specifics (which source, which audience, output shape) into the instructions the agent re-reads them on every run.
- Good: *"Summarize my unread emails since yesterday 6pm into a one-paragraph digest plus a bulleted list of action items. Skip newsletters and automated notifications."*
- Bad: *"Daily email summary."* (vague agent will improvise unhelpfully)
4. **Pick triggers.** All three are independently optional; mix freely.
- \`cronExpr\` — exact times. \`"0 7 * * *"\` = 7am daily.
- \`windows\` — time-of-day bands. Each fires once per day inside the band, anywhere — forgiving when the app was offline.
- \`eventMatchCriteria\` — a natural-language description of which incoming events should wake the task (e.g. "Emails about Q3 OKRs from the leadership team"). Pass-1 routing matches; the agent does Pass-2 before acting.
No triggers at all = manual-only. The user clicks Run.
5. **Call \`create-background-task\`.** Required: \`name\`, \`instructions\`. Optional: \`triggers\`, \`model\`, \`provider\` (leave model/provider unset unless the user explicitly asked). The tool returns a slug.
6. **Call \`run-background-task-agent\`** with the slug. The agent runs once and populates \`index.md\`.
7. **Confirm.** One line. Name the task. Point at the sidebar. Done.
## Extend, don't fork
When the user's new ask overlaps with an existing task — e.g. they say "also include X" or the ask is a refinement of an existing task's intent call \`patch-background-task\` instead of creating a duplicate.
Signals that you should extend:
- The user says "also …" / "and on top of that …" / "while you're at it …"
- The new ask is a refinement of an existing task's intent (different threshold, additional source, slightly different output)
When extending, pass the full rewritten \`instructions\` — don't try to surgical-edit a single sentence. The agent rereads instructions every run, so a clean rewrite is fine. After \`patch-background-task\` returns, call \`run-background-task-agent\` on the same slug so the user sees the updated output.
## Worked examples
### OUTPUT morning briefing
User: *"Every morning at 7, give me a one-paragraph summary of overnight news in AI agents."*
1. \`create-background-task\` with:
- \`name\`: "AI agent overnight news"
- \`instructions\`: "Search the web and Hacker News for news about AI agents (autonomous LLM agents, agentic frameworks, agent benchmarks) published in the last 24 hours. Summarize the top developments in one paragraph (3-5 sentences) followed by a 3-5 item bulleted list of the most significant items with a single-sentence note each. Replace the body of index.md."
- \`triggers\`: { \`cronExpr\`: "0 7 * * *" }
2. \`run-background-task-agent\` slug=ai-agent-overnight-news.
3. "Done — created the **AI agent overnight news** task. It'll run every morning at 7 and you can find it in Background tasks in the sidebar."
### ACTION email auto-reply
User: *"Whenever I get an email about Q3 planning, draft a reply asking when they're free this week."*
1. \`create-background-task\` with:
- \`name\`: "Q3 email auto-reply drafts"
- \`instructions\`: "When an event arrives describing an email thread about Q3 planning, use the Gmail draft-create tool to draft a reply to the latest message asking the sender when they're free for a 30-minute call this week. Do not send the draft — leave it in Drafts for me to review. After drafting, append a journal entry to index.md noting the thread subject and the draft id."
- \`triggers\`: { \`eventMatchCriteria\`: "Emails about Q3 planning (roadmap, OKRs, headcount, exec priorities)" }
2. \`run-background-task-agent\` slug=q3-email-auto-reply-drafts.
3. "Done — created the **Q3 email auto-reply drafts** task. It'll fire on relevant Gmail threads. Manage it from Background tasks in the sidebar."
### ACTION + journal Slack watcher
User: *"Every weekday morning at 9, post a summary of unresolved high-priority issues to #engineering on Slack."*
1. \`create-background-task\` with:
- \`name\`: "Daily eng triage"
- \`instructions\`: "Each run, query <issue tracker> for unresolved issues labeled priority:high or above. Summarize counts by owner and the three oldest items. Send the summary to #engineering via the Slack tool. After sending, append a journal entry to index.md with the timestamp and the message id."
- \`triggers\`: { \`cronExpr\`: "0 9 * * 1-5" }
2. \`run-background-task-agent\` slug=daily-eng-triage.
## Canonical Schema
\`\`\`yaml
${schemaYaml}
\`\`\`
Notes:
- \`active\` defaults to true. Patch \`{ active: false }\` to pause without deleting.
- \`createdAt\` and \`lastRun\` are runtime-managed — never write them yourself.
- The \`triggers\` block reuses Live Notes' \`Triggers\` schema verbatim. Cron grace and 5-minute backoff semantics are identical.
## Exceptions
The \`Background tasks\` sidebar view has a "New task" button that opens a form-driven flow. If the user is editing fields there or asking about a specific task from that view, *you* are not the right surface — the form is. Point at it ("You can also do this from the New task button in the Background tasks view") and step aside.
`;
export default skill;

View file

@ -1,237 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import builtinToolsSkill from "./builtin-tools/skill.js";
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
import docCollabSkill from "./doc-collab/skill.js";
import draftEmailsSkill from "./draft-emails/skill.js";
import mcpIntegrationSkill from "./mcp-integration/skill.js";
import meetingPrepSkill from "./meeting-prep/skill.js";
import organizeFilesSkill from "./organize-files/skill.js";
import createPresentationsSkill from "./create-presentations/skill.js";
import appNavigationSkill from "./app-navigation/skill.js";
import browserControlSkill from "./browser-control/skill.js";
import codeWithAgentsSkill from "./code-with-agents/skill.js";
import composioIntegrationSkill from "./composio-integration/skill.js";
import liveNoteSkill from "./live-note/skill.js";
import backgroundTaskSkill from "./background-task/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
// console.log(liveNoteSkill);
type SkillDefinition = {
id: string; // Also used as folder name
title: string;
summary: string;
content: string;
};
type ResolvedSkill = {
id: string;
catalogPath: string;
content: string;
};
const definitions: SkillDefinition[] = [
{
id: "create-presentations",
title: "Create Presentations",
summary: "Create PDF presentations and slide decks from natural language requests using knowledge base context.",
content: createPresentationsSkill,
},
{
id: "doc-collab",
title: "Document Collaboration",
summary: "Collaborate on documents - create, edit, and refine notes and documents in the knowledge base.",
content: docCollabSkill,
},
{
id: "draft-emails",
title: "Draft Emails",
summary: "Process incoming emails and create draft responses using calendar and knowledge base for context.",
content: draftEmailsSkill,
},
{
id: "meeting-prep",
title: "Meeting Prep",
summary: "Prepare for meetings by gathering context about attendees from the knowledge base.",
content: meetingPrepSkill,
},
{
id: "organize-files",
title: "Organize Files",
summary: "Find, organize, and tidy up files on the user's machine. Move files to folders, clean up Desktop/Downloads, locate specific files.",
content: organizeFilesSkill,
},
{
id: "builtin-tools",
title: "Builtin Tools Reference",
summary: "Understanding and using builtin tools (especially executeCommand for bash/shell) in agent definitions.",
content: builtinToolsSkill,
},
{
id: "mcp-integration",
title: "MCP Integration Guidance",
summary: "Discovering, executing, and integrating MCP tools. Use this to check what external capabilities are available and execute MCP tools on behalf of users.",
content: mcpIntegrationSkill,
},
{
id: "composio-integration",
title: "Composio Integration",
summary: "Interact with third-party services (Gmail, GitHub, Slack, LinkedIn, Notion, Jira, Google Sheets, etc.) via Composio. Search, connect, and execute tools.",
content: composioIntegrationSkill,
},
{
id: "deletion-guardrails",
title: "Deletion Guardrails",
summary: "Following the confirmation process before removing workflows or agents and their dependencies.",
content: deletionGuardrailsSkill,
},
{
id: "app-navigation",
title: "App Navigation",
summary: "Navigate the app UI - open notes, switch views, filter/search the knowledge base, and manage saved views.",
content: appNavigationSkill,
},
{
id: "code-with-agents",
title: "Code with Agents",
summary: "Write code, build projects, create scripts, or fix bugs by delegating to Claude Code or Codex via acpx.",
content: codeWithAgentsSkill,
},
{
id: "background-task",
title: "Background Tasks",
summary: "Set up a recurring background task — persistent instructions the agent fires on a schedule and/or on matching events (Gmail, Calendar). Either maintains an `index.md` digest (OUTPUT mode) or performs a recurring side-effect like drafting a reply / posting to Slack / calling an API (ACTION mode). Flagship surface for anything recurring.",
content: backgroundTaskSkill,
},
{
id: "live-note",
title: "Live Notes",
summary: "Make a specific markdown note self-updating — a single `live:` objective in the frontmatter that the live-note agent maintains on a schedule or on incoming events. Load only when the user explicitly says 'live note' / 'live-note'; for anything else recurring, prefer the background-task skill.",
content: liveNoteSkill,
},
{
id: "browser-control",
title: "Browser Control",
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
content: browserControlSkill,
},
{
id: "notify-user",
title: "Notify User",
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
content: notifyUserSkill,
},
];
const skillEntries = definitions.map((definition) => ({
...definition,
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
}));
const catalogSections = skillEntries.map((entry) => [
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath}\``,
`- **Use it for:** ${entry.summary}`,
].join("\n"));
export const skillCatalog = [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
"",
catalogSections.join("\n\n"),
].join("\n");
/**
* Build a skill catalog string, optionally excluding specific skills by ID.
*/
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
const entries = options?.excludeIds
? skillEntries.filter(e => !options.excludeIds!.includes(e.id))
: skillEntries;
const sections = entries.map((entry) => [
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath}\``,
`- **Use it for:** ${entry.summary}`,
].join("\n"));
return [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
"",
sections.join("\n\n"),
].join("\n");
}
const normalizeIdentifier = (value: string) =>
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
const aliasMap = new Map<string, ResolvedSkill>();
const registerAlias = (alias: string, entry: ResolvedSkill) => {
const normalized = normalizeIdentifier(alias);
if (!normalized) return;
aliasMap.set(normalized, entry);
};
const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
const normalized = normalizeIdentifier(alias);
if (!normalized) return;
const variants = new Set<string>([normalized]);
if (/\.(ts|js)$/i.test(normalized)) {
variants.add(normalized.replace(/\.(ts|js)$/i, ""));
variants.add(
normalized.endsWith(".ts") ? normalized.replace(/\.ts$/i, ".js") : normalized.replace(/\.js$/i, ".ts"),
);
} else {
variants.add(`${normalized}.ts`);
variants.add(`${normalized}.js`);
}
for (const variant of variants) {
registerAlias(variant, entry);
}
};
for (const entry of skillEntries) {
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
const resolvedEntry: ResolvedSkill = {
id: entry.id,
catalogPath: entry.catalogPath,
content: entry.content,
};
const baseAliases = [
entry.id,
`${entry.id}/skill`,
`${entry.id}/skill.ts`,
`${entry.id}/skill.js`,
`skills/${entry.id}/skill.ts`,
`skills/${entry.id}/skill.js`,
`${CATALOG_PREFIX}/${entry.id}/skill.ts`,
`${CATALOG_PREFIX}/${entry.id}/skill.js`,
absoluteTs,
absoluteJs,
];
for (const alias of baseAliases) {
registerAliasVariants(alias, resolvedEntry);
}
}
export const availableSkills = skillEntries.map((entry) => entry.id);
export function resolveSkill(identifier: string): ResolvedSkill | null {
const normalized = normalizeIdentifier(identifier);
if (!normalized) return null;
return aliasMap.get(normalized) ?? null;
}

View file

@ -1,124 +0,0 @@
const skill = String.raw`
# Slack Integration Skill (agent-slack CLI)
You interact with Slack by running **agent-slack** commands through \`executeCommand\`.
---
## 1. Check Connection
Before any Slack operation, read \`config/slack.json\` from the workspace root. If \`enabled\` is \`false\` or the \`workspaces\` array is empty, simply tell the user: "Slack is not enabled. You can enable it in the Connectors settings." Do not attempt any agent-slack commands.
If enabled, use the workspace URLs from the config for all commands.
---
## 2. Core Commands
### Messages
| Action | Command |
|--------|---------|
| List recent messages | \`agent-slack message list "#channel-name" --limit 25\` |
| List thread replies | \`agent-slack message list "#channel" --thread-ts 1234567890.123456\` |
| Get a single message | \`agent-slack message get "https://team.slack.com/archives/C.../p..."\` |
| Send a message | \`agent-slack message send "#channel-name" "Hello team!"\` |
| Reply in thread | \`agent-slack message send "#channel-name" "Reply text" --thread-ts 1234567890.123456\` |
| Edit a message | \`agent-slack message edit "#channel-name" --ts 1234567890.123456 "Updated text"\` |
| Delete a message | \`agent-slack message delete "#channel-name" --ts 1234567890.123456\` |
**Targets** can be:
- A full Slack URL: \`https://team.slack.com/archives/C01234567/p1234567890123456\`
- A channel name: \`"#general"\` or \`"general"\`
- A channel ID: \`C01234567\`
### Reactions
\`\`\`
agent-slack message react add "<target>" <emoji> --ts <ts>
agent-slack message react remove "<target>" <emoji> --ts <ts>
\`\`\`
### Search
\`\`\`
agent-slack search messages "query text" --limit 20
agent-slack search messages "query" --channel "#channel-name" --user "@username"
agent-slack search messages "query" --after 2025-01-01 --before 2025-02-01
agent-slack search files "query" --limit 10
\`\`\`
### Channels
\`\`\`
agent-slack channel new --name "project-x" --workspace https://team.slack.com
agent-slack channel new --name "secret-project" --private
agent-slack channel invite --channel "#project-x" --users "@alice,@bob"
\`\`\`
### Users
\`\`\`
agent-slack user list --limit 200
agent-slack user get "@username"
agent-slack user get U01234567
\`\`\`
### Canvases
\`\`\`
agent-slack canvas get "https://team.slack.com/docs/F01234567"
agent-slack canvas get F01234567 --workspace https://team.slack.com
\`\`\`
---
## 3. Multi-Workspace
**Important:** The user has chosen which workspaces to use. Before your first Slack operation, read \`config/slack.json\` from the workspace root to see the selected workspaces. Only interact with workspaces listed in that config — ignore any other authenticated workspaces.
If the selected workspace list contains multiple entries, use \`--workspace <url>\` to disambiguate:
\`\`\`
agent-slack message list "#general" --workspace https://team.slack.com
\`\`\`
If only one workspace is selected, always use \`--workspace\` with its URL to avoid ambiguity with other authenticated workspaces.
---
## 4. Token Budget Control
Use \`--limit\` to control how many messages/results are returned. Use \`--max-body-chars\` or \`--max-content-chars\` to truncate long message bodies:
\`\`\`
agent-slack message list "#channel" --limit 10
agent-slack search messages "query" --limit 5 --max-content-chars 2000
\`\`\`
---
## 5. Discovering More Commands
For any command you're unsure about:
\`\`\`
agent-slack --help
agent-slack message --help
agent-slack search --help
agent-slack channel --help
\`\`\`
---
## Best Practices
- **Always show drafts before sending** Never send Slack messages without user confirmation
- **Summarize, don't dump** When showing channel history, summarize the key points rather than pasting everything
- **Prefer Slack URLs** When referring to messages, use Slack URLs over raw channel names when available
- **Use --limit** Always set reasonable limits to keep output concise and token-efficient
- **Resolve user IDs** Messages contain raw user IDs like \`U078AHJP341\`. Resolve them to real names before presenting to the user. Batch all lookups into a single \`executeCommand\` call using \`;\` separators, e.g. \`agent-slack user get U078AHJP341 --workspace ... ; agent-slack user get U090UEZCEQ0 --workspace ...\`
- **Cross-reference with knowledge base** Check if mentioned people have notes in the knowledge base
`;
export default skill;

View file

@ -1,52 +0,0 @@
export const skill = String.raw`
# Web Search Skill
You have access to two search tools for finding information on the internet. Choose the right one based on the user's intent.
## Tools
### web-search (Brave Search)
Quick, general-purpose web search. Returns titles, URLs, and short descriptions.
**Best for:**
- Quick lookups for things that change ("current price of Bitcoin", "weather in SF")
- Current events and breaking news
- Finding a specific website or page
- Simple questions with direct answers
- Checking a fact or date
### research-search (Exa Search)
Deep, research-oriented search. Returns full article text, highlights, and metadata (author, published date).
**Best for:**
- Exploring a topic in depth ("what are the latest advances in CRISPR")
- Finding articles, blog posts, papers, and quality sources
- Discovering companies, people, or organizations
- Research where you need rich context, not just links
- When the user says "research", "find articles about", "look into", "deep dive"
**Category filter:** Use the category parameter when the user's intent clearly maps to one: company, research paper, news, tweet, personal site, financial report, people.
## How Many Searches to Do
**CRITICAL: Always start with exactly ONE search call.** Pick the single best tool (\`web-search\` or \`research-search\`) and make one request. Wait for the result before deciding if more searches are needed.
**NEVER call multiple search tools simultaneously.** No parallel web-search + research-search. No firing off two web-searches at once. Always sequential: one search at a time.
Only make a follow-up search if:
- The first search returned truly uninformative or irrelevant results
- The query has clearly distinct sub-topics that the first search couldn't cover (e.g., "compare X and Y" after getting results for X only)
- The user explicitly asks you to dig deeper
One good search is almost always enough. Default to one and stop.
## Choosing Between the Two
If both tools are attached, prefer:
- \`web-search\` when the user wants a quick answer or specific link
- \`research-search\` when the user wants to learn, explore, or gather sources
If only one is attached, use whichever is available.
`;
export default skill;

View file

@ -6,7 +6,7 @@ import { createInterface } from "readline";
import { execSync } from "child_process";
import { glob } from "glob";
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { ISkillResolver } from "../../skills/resolver.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js";
@ -135,27 +135,39 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
loadSkill: {
description: "Load a Rowboat skill definition into context by fetching its guidance string",
inputSchema: z.object({
skillName: z.string().describe("Skill identifier or path (e.g., 'workflow-run-ops' or 'src/application/assistant/skills/workflow-run-ops/skill.ts')"),
skillName: z.string().describe("Skill identifier (e.g., 'doc-collab', 'web-search')"),
}),
execute: async ({ skillName }: { skillName: string }) => {
const resolved = resolveSkill(skillName);
const resolver = container.resolve<ISkillResolver>("skillResolver");
const resolved = await resolver.resolve(skillName);
if (!resolved) {
const catalog = await resolver.getCatalog();
const available = catalog.map((s) => s.id).join(", ");
return {
success: false,
message: `Skill '${skillName}' not found. Available skills: ${availableSkills.join(", ")}`,
message: `Skill '${skillName}' not found. Available skills: ${available}`,
};
}
return {
success: true,
skillName: resolved.id,
path: resolved.catalogPath,
content: resolved.content,
};
},
},
listSkills: {
description: "List all available skills with their id, title, and one-line summary",
inputSchema: z.object({}),
execute: async () => {
const resolver = container.resolve<ISkillResolver>("skillResolver");
const catalog = await resolver.getCatalog();
return { success: true, skills: catalog };
},
},
'workspace-getRoot': {
description: 'Get the workspace root directory path',
inputSchema: z.object({}),

View file

@ -1,10 +1,12 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../application/lib/builtin-tools.js';
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../application/lib/knowledge-note-style.js';
import container from '../di/container.js';
import type { ISkillResolver } from '../skills/resolver.js';
import { WorkDir } from '../config/config.js';
export const BACKGROUND_TASK_AGENT_INSTRUCTIONS = `You are the background-task agent — a self-running agent that fires on a schedule and/or in response to incoming events to act on persistent **instructions** the user wrote.
function buildInstructions(knowledgeNoteStyle: string): string {
return `You are the background-task agent — a self-running agent that fires on a schedule and/or in response to incoming events to act on persistent **instructions** the user wrote.
You are running with **no user present** to clarify, approve, or watch.
- Do NOT ask clarifying questions make the most reasonable interpretation of the instructions and proceed.
@ -49,7 +51,7 @@ The run message tells you which trigger fired and how to interpret it:
# Workspace conventions
${KNOWLEDGE_NOTE_STYLE_GUIDE}
${knowledgeNoteStyle}
# Failure and fallback
@ -67,18 +69,23 @@ Avoid: "I updated the file.", "Done!", "Here is the update:". The summary is a d
The workspace lives at \`${WorkDir}\`.
`;
}
export function buildBackgroundTaskAgent(): z.infer<typeof Agent> {
export async function buildBackgroundTaskAgent(): Promise<z.infer<typeof Agent>> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
tools[name] = { type: 'builtin', name };
}
const resolver = container.resolve<ISkillResolver>('skillResolver');
const styleSkill = await resolver.resolve('knowledge-note-style');
const styleGuide = styleSkill?.content ?? '';
return {
name: 'background-task-agent',
description: 'Background agent that runs on a schedule/event and either keeps a task\'s index.md current (OUTPUT mode) or performs a recurring side-effect and journals it (ACTION mode).',
instructions: BACKGROUND_TASK_AGENT_INSTRUCTIONS,
instructions: buildInstructions(styleGuide),
tools,
};
}

View file

@ -15,6 +15,8 @@ import { IAbortRegistry, InMemoryAbortRegistry } from "../runs/abort-registry.js
import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.js";
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import { FSOfficialSkillsRepo, IOfficialSkillsRepo } from "../skills/official-repo.js";
import { SkillResolver, ISkillResolver } from "../skills/resolver.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
@ -41,6 +43,8 @@ container.register({
agentScheduleRepo: asClass<IAgentScheduleRepo>(FSAgentScheduleRepo).singleton(),
agentScheduleStateRepo: asClass<IAgentScheduleStateRepo>(FSAgentScheduleStateRepo).singleton(),
slackConfigRepo: asClass<ISlackConfigRepo>(FSSlackConfigRepo).singleton(),
officialSkillsRepo: asClass<IOfficialSkillsRepo>(FSOfficialSkillsRepo).singleton(),
skillResolver: asClass<ISkillResolver>(SkillResolver).singleton(),
});
export default container;
@ -56,3 +60,9 @@ export function registerNotificationService(service: INotificationService): void
notificationService: asValue(service),
});
}
export function registerSkillsDir(skillsDir: string): void {
container.register({
skillsDir: asValue(skillsDir),
});
}

View file

@ -1,10 +1,12 @@
import z from 'zod';
import { Agent, ToolAttachment } from '@x/shared/dist/agent.js';
import { BuiltinTools } from '../../application/lib/builtin-tools.js';
import { KNOWLEDGE_NOTE_STYLE_GUIDE } from '../../application/lib/knowledge-note-style.js';
import container from '../../di/container.js';
import type { ISkillResolver } from '../../skills/resolver.js';
import { WorkDir } from '../../config/config.js';
export const LIVE_NOTE_AGENT_INSTRUCTIONS = `You are the live-note agent — a background agent that keeps a *live note* in the user's personal knowledge base current with its objective.
function buildInstructions(knowledgeNoteStyle: string): string {
return `You are the live-note agent — a background agent that keeps a *live note* in the user's personal knowledge base current with its objective.
Your goal on each run: bring the body of the note in line with the user's persistent **objective** for that note. The user is maintaining a personal knowledge base and will scan this note alongside many others optimize for **information density and scannability**, not conversational prose.
@ -78,7 +80,7 @@ Unless the objective explicitly specifies a different structure, follow this def
If the objective says something specific about layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly and ignore the defaults.
${KNOWLEDGE_NOTE_STYLE_GUIDE}
${knowledgeNoteStyle}
The style guide above is the canonical writing style for everything you emit into the body. The objective may specify a particular shape ("3-column markdown table: Location | Local Time | Offset") when it does, follow it exactly. When it doesn't, walk the ladder above and pick the tightest shape that fits the data.
@ -307,18 +309,23 @@ State the action and the substance. Good examples:
Avoid: "I updated the note.", "Done!", "Here is the update:". The summary is a data point, not a sign-off.
`;
}
export function buildLiveNoteAgent(): z.infer<typeof Agent> {
export async function buildLiveNoteAgent(): Promise<z.infer<typeof Agent>> {
const tools: Record<string, z.infer<typeof ToolAttachment>> = {};
for (const name of Object.keys(BuiltinTools)) {
if (name === 'executeCommand') continue;
tools[name] = { type: 'builtin', name };
}
const resolver = container.resolve<ISkillResolver>('skillResolver');
const styleSkill = await resolver.resolve('knowledge-note-style');
const styleGuide = styleSkill?.content ?? '';
return {
name: 'live-note-agent',
description: 'Background agent that keeps a live note up to date with its objective',
instructions: LIVE_NOTE_AGENT_INSTRUCTIONS,
instructions: buildInstructions(styleGuide),
tools,
};
}

View file

@ -0,0 +1,50 @@
import fs from "node:fs/promises";
import path from "node:path";
import { parseSkillMd } from "./skill-md-parser.js";
import type { SkillDefinition } from "./types.js";
export interface IOfficialSkillsRepo {
listOfficial(): Promise<SkillDefinition[]>;
getOfficial(id: string): Promise<SkillDefinition | null>;
}
export class FSOfficialSkillsRepo implements IOfficialSkillsRepo {
private readonly officialDir: string;
constructor({ skillsDir }: { skillsDir: string }) {
this.officialDir = skillsDir;
}
async listOfficial(): Promise<SkillDefinition[]> {
const result: SkillDefinition[] = [];
let entries: string[];
try {
entries = await fs.readdir(this.officialDir);
} catch {
return result;
}
for (const entry of entries) {
const skillMdPath = path.join(this.officialDir, entry, "SKILL.md");
try {
const raw = await fs.readFile(skillMdPath, "utf-8");
result.push(parseSkillMd(raw, entry));
} catch {
// Not a valid skill directory, skip
}
}
result.sort((a, b) => a.id.localeCompare(b.id));
return result;
}
async getOfficial(id: string): Promise<SkillDefinition | null> {
const skillMdPath = path.join(this.officialDir, id, "SKILL.md");
try {
const raw = await fs.readFile(skillMdPath, "utf-8");
return parseSkillMd(raw, id);
} catch {
return null;
}
}
}

View file

@ -0,0 +1,65 @@
import { ResolvedSkill, SkillCatalogEntry } from "@x/shared/dist/skill.js";
import { IOfficialSkillsRepo } from "./official-repo.js";
const INCLUDE_DIRECTIVE = /\{\{include:([a-z0-9][a-z0-9_-]*)\}\}/g;
export interface ISkillResolver {
getCatalog(): Promise<SkillCatalogEntry[]>;
resolve(id: string): Promise<ResolvedSkill | null>;
}
export class SkillResolver implements ISkillResolver {
private readonly officialSkillsRepo: IOfficialSkillsRepo;
constructor({ officialSkillsRepo }: { officialSkillsRepo: IOfficialSkillsRepo }) {
this.officialSkillsRepo = officialSkillsRepo;
}
async getCatalog(): Promise<SkillCatalogEntry[]> {
const all = await this.officialSkillsRepo.listOfficial();
return all
.filter((s) => !s.hidden)
.map(({ id, title, summary }) => ({ id, title, summary }));
}
async resolve(id: string): Promise<ResolvedSkill | null> {
return this.resolveInner(id, new Set());
}
private async resolveInner(id: string, seen: Set<string>): Promise<ResolvedSkill | null> {
if (seen.has(id)) {
// Cycle: emit a placeholder rather than infinite-looping.
return null;
}
const def = await this.officialSkillsRepo.getOfficial(id);
if (!def) return null;
const nextSeen = new Set(seen);
nextSeen.add(id);
const expanded = await this.expandIncludes(def.content, nextSeen);
return {
id: def.id,
title: def.title,
summary: def.summary,
content: expanded,
};
}
private async expandIncludes(content: string, seen: Set<string>): Promise<string> {
const matches = Array.from(content.matchAll(INCLUDE_DIRECTIVE));
if (matches.length === 0) return content;
const replacements = new Map<string, string>();
for (const match of matches) {
const directive = match[0];
const includeId = match[1];
if (replacements.has(directive)) continue;
const resolved = await this.resolveInner(includeId, seen);
replacements.set(
directive,
resolved?.content ?? `<!-- missing skill include: ${includeId} -->`,
);
}
return content.replace(INCLUDE_DIRECTIVE, (whole) => replacements.get(whole) ?? whole);
}
}

View file

@ -0,0 +1,33 @@
import { parse } from "yaml";
import { SkillFrontmatter } from "@x/shared/dist/skill.js";
import type { SkillDefinition } from "./types.js";
/**
* Parse a SKILL.md file (YAML frontmatter + markdown body) into a SkillDefinition.
* Follows the Agent Skills spec: frontmatter between --- markers.
*/
export function parseSkillMd(raw: string, fallbackId?: string): SkillDefinition {
const normalized = raw.replace(/\r\n/g, "\n");
if (!normalized.startsWith("---\n")) {
throw new Error("SKILL.md missing frontmatter (must start with ---)");
}
const end = normalized.indexOf("\n---\n", 4);
const lastEnd = normalized.endsWith("\n---") ? normalized.length - 4 : -1;
const closingIdx = end !== -1 ? end : lastEnd;
if (closingIdx === -1) {
throw new Error("SKILL.md has malformed frontmatter (missing closing ---)");
}
const fm = normalized.slice(4, closingIdx).trim();
const body = normalized.slice(closingIdx + 4).trim();
const parsed = SkillFrontmatter.parse(parse(fm));
return {
id: parsed.name ?? fallbackId ?? "unknown",
title: parsed.metadata?.title ?? parsed.name,
summary: parsed.description,
hidden: parsed.hidden ?? false,
content: body,
};
}

View file

@ -0,0 +1,7 @@
export type SkillDefinition = {
id: string;
title: string;
summary: string;
hidden: boolean;
content: string;
};

View file

@ -16,4 +16,5 @@ export * as promptBlock from './prompt-block.js';
export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export * as browserControl from './browser-control.js';
export * as skill from './skill.js';
export { PrefixLogger };

View file

@ -17,6 +17,7 @@ import { UserMessageContent } from './message.js';
import { RowboatApiConfig } from './rowboat-account.js';
import { ZListToolkitsResponse } from './composio.js';
import { BrowserStateSchema } from './browser-control.js';
import { ResolvedSkill, SkillCatalogEntry } from './skill.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -871,6 +872,17 @@ const ipcSchemas = {
req: BrowserStateSchema,
res: z.null(),
},
// Skills channels (read-only)
'skills:list': {
req: z.null(),
res: z.object({
skills: z.array(SkillCatalogEntry),
}),
},
'skills:get': {
req: z.object({ id: z.string() }),
res: ResolvedSkill.nullable(),
},
// Billing channels
'billing:getInfo': {
req: z.null(),

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
// SKILL.md frontmatter schema. `name` is the skill id (folder name) and
// `description` is the one-line catalog summary. `hidden: true` keeps a
// skill out of the public catalog while still allowing other skills to
// `{{include:<id>}}` it as content (e.g. shared style guides).
export const SkillFrontmatter = z.object({
name: z.string().max(64),
description: z.string().max(1024),
hidden: z.boolean().optional(),
license: z.string().optional(),
metadata: z.object({
title: z.string().optional(),
}).passthrough().optional(),
});
export type SkillFrontmatter = z.infer<typeof SkillFrontmatter>;
// Skill catalog entry seen by the agent and the renderer (no content body).
export const SkillCatalogEntry = z.object({
id: z.string(),
title: z.string(),
summary: z.string(),
});
export type SkillCatalogEntry = z.infer<typeof SkillCatalogEntry>;
// Fully-resolved skill: catalog metadata + body with all {{include:<id>}}
// directives expanded.
export const ResolvedSkill = z.object({
id: z.string(),
title: z.string(),
summary: z.string(),
content: z.string(),
});
export type ResolvedSkill = z.infer<typeof ResolvedSkill>;