Hooks & Scheduled Tasks

Hooks, Scheduled Tasks & Custom Tools

SideCar provides four extensibility surfaces that run shell commands or agent tasks in response to tool calls, file-system events, schedules, or file saves — all without writing TypeScript or building a VS Code extension.

Trust gate: All four features (sidecar.hooks, sidecar.eventHooks, sidecar.scheduledTasks, sidecar.customTools) are workspace-trust-gated. When a repository defines them in .vscode/settings.json, SideCar surfaces a one-time confirmation prompt before activating them. User-level settings (~/.config/Code/User/settings.json) are always trusted.


Tool hooks

Run a shell command immediately before (pre) or after (post) any tool execution. Keys are tool names or * to match all tools.

"sidecar.hooks": {
  "write_file": {
    "post": "npx prettier --write \"$SIDECAR_FILE\""
  },
  "edit_file": {
    "post": "npx prettier --write \"$SIDECAR_FILE\""
  },
  "git_commit": {
    "pre": "npm run lint --silent"
  },
  "*": {
    "pre": "echo \"$SIDECAR_TOOL $SIDECAR_INPUT\" >> /tmp/sidecar-audit.log"
  }
}

Environment variables

Variable Available Description
SIDECAR_TOOL pre, post Tool name being executed (e.g. write_file)
SIDECAR_INPUT pre, post JSON-encoded input parameters
SIDECAR_FILE pre, post Value of the path input field, if the tool has one
SIDECAR_OUTPUT post only Tool execution result (string)

Hook execution order

When a tool matches both a specific key and *, the specific hook runs first, then *. For each key, pre runs before the tool, post runs after.

A non-zero exit code from a pre hook cancels the tool call and returns the hook output as an error — useful for enforcing pre-conditions. A non-zero exit from a post hook is logged as a warning but does not affect the tool result already returned to the agent.

Recipes

Auto-format after every write:

"sidecar.hooks": {
  "write_file": { "post": "npx prettier --write \"$SIDECAR_FILE\" 2>/dev/null || true" },
  "edit_file":  { "post": "npx prettier --write \"$SIDECAR_FILE\" 2>/dev/null || true" }
}

Require lint to pass before any commit:

"sidecar.hooks": {
  "git_commit": { "pre": "npm run lint --silent" }
}

Audit log of every tool the agent calls:

"sidecar.hooks": {
  "*": { "pre": "echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) $SIDECAR_TOOL\" >> .sidecar/logs/tool-audit.log" }
}

Block pushes to main branch:

"sidecar.hooks": {
  "git_push": { "pre": "branch=$(git rev-parse --abbrev-ref HEAD); [ \"$branch\" != 'main' ] || (echo 'Direct push to main blocked.' && exit 1)" }
}

Event hooks

Run a shell command when VS Code fires a file-system event. Unlike tool hooks, these fire on any save or create — even edits the user makes manually, not just agent actions.

"sidecar.eventHooks": {
  "onSave":   "npx eslint --fix \"$SIDECAR_FILE\" --quiet 2>/dev/null || true",
  "onCreate": "echo \"New file: $SIDECAR_FILE\" >> .sidecar/logs/created.log",
  "onDelete": "git rm --cached \"$SIDECAR_FILE\" 2>/dev/null || true"
}
Event Triggers when $SIDECAR_FILE value
onSave Any file is saved (Cmd+S or auto-save) Absolute path of saved file
onCreate A new file is created Absolute path of new file
onDelete A file is deleted Absolute path of deleted file

Event hooks receive $SIDECAR_FILE (the absolute path of the affected file) and $SIDECAR_TOOL (the event name: onSave, onCreate, onDelete).

Recipe — keep an index file in sync

Regenerate a barrel export whenever a TypeScript file is saved in src/components/:

"sidecar.eventHooks": {
  "onSave": "if [[ \"$SIDECAR_FILE\" == */src/components/*.ts ]]; then node scripts/gen-barrel.js; fi"
}

Scheduled tasks

Run an agent task on a recurring interval, a cron schedule, or whenever a matching file is saved. Each task fires a full agent loop with the configured prompt.

"sidecar.scheduledTasks": [
  {
    "name": "daily-standup-prep",
    "cron": "0 8 * * 1-5",
    "prompt": "Review the last 24 hours of git commits. Write a concise standup summary to .sidecar/standup.md: what was done, what's blocked, what's next.",
    "enabled": true
  },
  {
    "name": "lint-on-ts-save",
    "onSave": ["src/**/*.ts", "src/**/*.tsx"],
    "prompt": "The file $SIDECAR_FILE was just saved. Run the linter and fix any new warnings introduced in this file only.",
    "enabled": true
  },
  {
    "name": "hourly-test-pulse",
    "intervalMinutes": 60,
    "prompt": "Run npm test. If any tests are failing, identify the root cause and create a GitHub issue with the failure details.",
    "enabled": false
  }
]

Field reference

Field Type Required Description
name string Yes Unique display name — also used as the lock key to prevent overlapping runs
prompt string Yes Natural-language prompt sent to the agent
enabled boolean No true by default. Set false to pause without removing the entry
intervalMinutes number No Run every N minutes (minimum 1). Ignored when cron is set
cron string No 5-field cron expression. Takes precedence over intervalMinutes
onSave string[] No Run when a saved file matches any of these globs
targetPaths string[] No Files the task will write. When set, tasks with unsaved changes in these paths run in a Shadow Workspace

Exactly one trigger type should be set per task. If both cron and intervalMinutes are present, cron wins.

Cron expression syntax

┌─── minute (0-59)
│  ┌── hour (0-23)
│  │  ┌─ day of month (1-31)
│  │  │  ┌ month (1-12)
│  │  │  │  ┌ day of week (0-7, 0 and 7 = Sunday)
│  │  │  │  │
*  *  *  *  *
Expression Meaning
0 9 * * 1-5 Weekdays at 09:00
*/30 * * * * Every 30 minutes
0 0 * * 0 Sunday midnight
0 8,17 * * 1-5 Weekdays at 08:00 and 17:00
0 */4 * * * Every 4 hours

Running a task immediately

Without waiting for the next scheduled fire:

Cmd+Shift+P → SideCar: Run Scheduled Task Now

A QuickPick lists all enabled tasks by name. Selecting one fires it immediately.

The onSave file trigger

When onSave globs are set, the task fires within a few seconds of a matching file being saved — useful for reactive agents that keep derived artifacts in sync.

The saved file path is injected into the prompt as $SIDECAR_FILE:

{
  "name": "update-snapshots",
  "onSave": ["src/**/*.test.ts"],
  "prompt": "The test file $SIDECAR_FILE was saved. Run its tests with --updateSnapshot to refresh any outdated snapshots, then stage the updated snapshot files."
}

Protecting writes with Shadow Workspaces

When targetPaths is set and any of those files have unsaved local changes, the task runs inside a Shadow Workspace — the agent’s writes land in an isolated git worktree until you accept or reject the result.

{
  "name": "weekly-changelog",
  "cron": "0 9 * * 5",
  "prompt": "Generate a CHANGELOG entry for the week's commits and prepend it to CHANGELOG.md.",
  "targetPaths": ["CHANGELOG.md"],
  "enabled": true
}

Practical examples

Daily dependency audit (Mon–Fri 09:00):

{
  "name": "dep-audit",
  "cron": "0 9 * * 1-5",
  "prompt": "Run 'npm audit'. If there are high-severity vulnerabilities, create a GitHub issue listing them and assign it to me."
}

Keep API docs in sync with route changes:

{
  "name": "openapi-sync",
  "onSave": ["src/routes/**/*.ts"],
  "prompt": "The route file $SIDECAR_FILE was saved. Regenerate the OpenAPI spec for that route and update docs/api/ accordingly."
}

Bundle size watchdog (every 2 hours):

{
  "name": "bundle-watchdog",
  "intervalMinutes": 120,
  "prompt": "Build the project and check the bundle size. If it exceeds 500KB, identify the largest contributors and open a GitHub issue."
}

Custom tools

Register any shell command as an agent tool. Custom tools appear in the agent’s tool catalog alongside built-ins and go through the same approval flow.

"sidecar.customTools": [
  {
    "name": "deploy_staging",
    "description": "Deploy the current branch to the staging environment. Use after tests pass to get feedback before production.",
    "command": "npm run deploy:staging -- --branch $SIDECAR_INPUT"
  },
  {
    "name": "send_slack",
    "description": "Send a message to the #dev Slack channel. Pass the message text as input.",
    "command": "curl -s -X POST $SLACK_WEBHOOK -d '{\"text\":\"'\"$SIDECAR_INPUT\"'\"}'"
  },
  {
    "name": "db_migrate",
    "description": "Run pending database migrations in the development environment.",
    "command": "npx prisma migrate dev --name $SIDECAR_INPUT"
  }
]

Field reference

Field Required Description
name Yes Tool identifier — becomes custom_<name> in the agent catalog
description Yes What the tool does. The model reads this to decide when to call it — write it clearly
command Yes Shell command. $SIDECAR_INPUT is replaced with the agent’s string argument

How $SIDECAR_INPUT works

When the agent calls custom_deploy_staging("main"), $SIDECAR_INPUT is set to main in the child process environment. The input string passes through redactSecrets() before being set, so hallucinated credentials can’t leak via environment variables.

Approval behavior

Custom tools always require user approval per call — there is no way to auto-allow them via sidecar.toolPermissions. This is intentional: custom tools have arbitrary shell access and the consequence of a wrong call cannot be bounded by SideCar.

The approval prompt shows both the tool name and the full command with $SIDECAR_INPUT substituted so you see exactly what will run before confirming.

Trust gating

Custom tools in .vscode/settings.json require workspace trust. A cloned repository cannot inject custom_exfiltrate silently — the user must accept the workspace trust prompt first. User-level custom tools are always active.


Putting it together

These four surfaces compose cleanly:

  • Tool hooks react to the agent’s tool calls, synchronously, in the same process context as the agent turn.
  • Event hooks react to the file system independently of whether the agent is running.
  • Scheduled tasks run a full autonomous agent loop on a timer or file-save trigger.
  • Custom tools give the agent new capabilities defined by your team.

A complete workspace automation setup might combine all four:

{
  "sidecar.hooks": {
    "write_file": { "post": "npx prettier --write \"$SIDECAR_FILE\" 2>/dev/null || true" }
  },
  "sidecar.eventHooks": {
    "onSave": "[ \"${SIDECAR_FILE##*.}\" = 'ts' ] && npx eslint --fix \"$SIDECAR_FILE\" --quiet 2>/dev/null || true"
  },
  "sidecar.scheduledTasks": [
    {
      "name": "morning-pr-review",
      "cron": "0 9 * * 1-5",
      "prompt": "List all open PRs. For each one, run /review and post the findings as a GitHub review comment.",
      "enabled": true
    }
  ],
  "sidecar.customTools": [
    {
      "name": "open_ticket",
      "description": "Create a Linear ticket for a bug or task. Pass the title as input.",
      "command": "linear create --title \"$SIDECAR_INPUT\" --team ENG --status Backlog"
    }
  ]
}