A plugin is a self-contained directory that reacts to task events. Drop it
into ~/.config/task/plugins/ and it's live — no rebuild, no config edits, and
no collisions with other plugins. This is the easy on-ramp for community
integrations (notifications, proxies, trackers, chat bridges, …).
It builds on TaskYou's existing event hooks. The
difference: the legacy hooks dir allows one script per event, so two
integrations that both want task.done fight over the same file. A plugin
namespaces its scripts in its own directory and declares what it handles in a
manifest, so any number of plugins can handle the same event and all of them
run.
You should not have to know that a repo of plugins exists to install one. ty carries a catalog: a small JSON index of installable plugins, compiled into the binary (so search works offline, on a fresh install) and refreshed in the background from taskyou.dev/registry.json.
Every surface searches the same catalog:
| Surface | How |
|---|---|
| TUI | press m on the board — type to search, enter installs, ctrl+d removes, tab cycles All / Installed / Available |
| GUI / browser | the Plugins view (m, ⌘M, or the menu) |
| CLI | ty plugins browse · ty plugins search <term> · ty plugins info <id> |
ty plugins search slack # → slack Slack notifications Posts task updates to…
ty plugins info slack # what it does, what it needs, where it comes from
ty plugins add slack # install it by namety plugins add slack # by catalog name (see above)
ty plugins add taskyou/plugins # owner/repo shorthand
ty plugins add https://github.com/taskyou/plugins # a git URL
ty plugins add ./my-plugin # a local pathInstalling by catalog name takes just that plugin, even when it lives inside a collection repo alongside nine others — the repo is cloned to a staging dir and only that subdirectory is copied in, so the installed plugin is a plain directory with no dependency on the collection.
Installing a whole repo clones it into your plugins dir; a repo can hold one plugin at its root or many nested in subdirectories, and every plugin inside becomes active.
Either way, re-running add on something already installed updates it in place.
ty plugins update # update everything installed
ty plugins update rpi # or just onety records where each plugin came from (in <plugins-dir>/.sources.json) at install
time, so update works for a plugin lifted out of a collection as well as for a
whole checkout — where there is no git remote to ask.
You can also just drop a directory into ~/.config/task/plugins/ by hand — see
Examples. A hand-copied plugin still shows up in the browser (marked
local), so you can see and remove it there.
TY_PLUGIN_REGISTRY replaces the catalog URL list (comma-separated). Setting it to
an empty string disables remote catalogs entirely and uses only the snapshot
compiled into the binary — which is what an air-gapped install and the test suite
want. Remote catalogs overlay the bundled snapshot by entry ID, so a published
catalog can correct or extend what a shipped binary knows without a release; a
failed fetch degrades to the on-disk cache, then to the bundle, and never to an
error.
From the browser: ctrl+d in the TUI, the trash button in the GUI. From the CLI:
ty plugins remove <name> # aliases: rm, uninstallremove deletes the named plugin's directory (the name is the one from
ty plugins list, even if it differs from the directory name). If the plugin was
installed as part of a multi-plugin collection checkout, only its own subdirectory
is removed — the shared checkout and its sibling plugins stay, and re-running
ty plugins add on the collection source may restore it.
~/.config/task/plugins/
└── my-plugin/
├── plugin.yaml # manifest (required)
└── on-done.sh # your script(s)
name: my-plugin # required, unique
version: 0.1.0 # optional
description: What it does. # optional
hooks: # event -> script path (relative to this dir)
task.done: on-done.sh
task.blocked: on-blocked.sh
actions: # user-invoked commands (optional)
- id: sync
label: Sync to tracker
command: sync.sh
services: # long-running processes (optional)
- name: index
command: ./search-server # sh -c, run from the plugin dir
cwd: "" # optional, relative to the plugin dir
env: ["FOO=bar"] # optional extra envScript paths are resolved relative to the plugin directory. Scripts must be
executable (chmod +x). A plugin needs at least one usable hook, action,
workflow (workflows/*.yaml), or service.
A service is a process the daemon supervises for its whole lifetime: it starts
when the daemon comes up and is stopped (SIGTERM, then SIGKILL) when the daemon exits.
Each service runs as sh -c <command> from the plugin dir (or cwd), in its own
process group. See examples/plugins/heartbeat/ for
a runnable example.
A hook is fired by the daemon on one discrete event (task.done), runs, and
exits — it's stateless and short-lived, and it can only ever react to a single event
in isolation. A service is the opposite: it owns its own loop, and it's for the
work a hook structurally can't do:
- Hold a persistent connection — a Slack socket-mode client, an IMAP IDLE connection, an MCP stdio server other tools attach to.
- Run on its own schedule — poll an inbox every 30s, reindex every 5min — rather than waiting for a ty event to fire it.
- Serve a port — a metrics endpoint, a small web dashboard, a search server.
- Batch or debounce across many events — a hook fires once per event and keeps no state between fires; a service can accumulate and flush.
- Watch an external source and create tasks — turn emails, GitHub webhooks, or a file watcher into ty tasks.
Rule of thumb: react to one ty event → hook. Hold state, a connection, a schedule, or a port → service.
A service is just a process — it has no special in-process access to ty. The daemon hands it, via the environment, a stable way to find the running instance:
| env var | value | use it for |
|---|---|---|
TY_API_URL |
http://127.0.0.1:8080 (the daemon's HTTP API; absent if the API is disabled) |
Read/write tasks over HTTP — the decoupled, recommended path. |
TY_DB_PATH |
the SQLite file (respects WORKTREE_DB_PATH) |
Open the DB read-only for queries the API doesn't expose yet. Couples you to the schema — prefer the API. |
Beyond those, a service can subscribe to ty's event feed (the SSE stream on the
HTTP API) to react to task changes in real time, or simply shell out to the ty
CLI, which is often the least-effort option for a small shell service.
- Email/chat bridge — hold an IMAP IDLE or Slack socket connection; turn incoming
messages into tasks and post task updates back out. (This is the shape of the
existing
ty-emailextension — the kind of thing that used to run and be babysat on its own.) - Semantic search / index server — keep an embeddings index warm and answer
queries over a port or via MCP. (The shape of
ty-qmd.) - Webhook listener — bind a port, accept GitHub/Linear webhooks, and create tasks from them.
- Metrics / dashboard — serve board stats read from
TY_API_URLon a small HTTP endpoint for a status display. - Reactive reindexer / cache warmer — subscribe to the event feed and rebuild a derived view whenever tasks change.
Note: declaring a service does not migrate any of the existing in-repo extensions — they stay where they are. This is simply the capability so a new plugin can ship a long-running helper if it wants one.
Plugins handle the same events as the event hooks system. The ones dispatched today:
| Event | When |
|---|---|
task.started |
Execution begins |
task.done |
Agent finished successfully |
task.blocked |
Task needs input |
task.failed |
Agent execution failed |
task.auth_required |
Executor session needs re-authentication |
task.route |
Consulted before a task spawns: which profile runs it. See Routing |
task.placement |
Consulted before a task spawns: which machine runs it. See below |
task.hosts |
Consulted while a new-task form is open: which machines could run it. See Offering a choice of machines |
A plugin may declare any event string; it only runs for events TaskYou actually emits, so unknown events are harmless.
Every other hook is a notification — it fires after the fact and nothing waits for
it. task.route fires before a task spawns, TaskYou waits for it, and reads the
script's stdout back as a decision. Stdout is the decision channel; put
diagnostics on stderr.
#!/bin/sh
echo "CLAUDE_CONFIG_DIR=$HOME/.claude-work" # run this task under that profile
echo "REASON=7% of its limits used" # optional, for the task log| Key | Effect |
|---|---|
CLAUDE_CONFIG_DIR |
Run the task under that Claude profile (config dir) |
HOLD=1 |
Don't start yet; leave it queued and reconsider next tick |
REASON=… |
Free text for the task log |
Unrecognized lines are ignored. Timeout is 15s. Plugins are consulted in name order and the first non-empty decision wins.
Failing is safe: no router, a script that errors or prints nothing, or a timeout
all spawn the task exactly as it would have. A config dir already set by hand, by
a workflow step, or on the project is never overruled — pinning a project's
config dir is how you opt it out of routing, which matters because a config dir
carries that account's MCP connectors and their per-profile OAuth logins, and a routed task keeps its profile on
resume (its Claude session lives in that config dir). HOLD leaves a task
queued, never blocked, and is ignored for a manually started task. Only
Claude tasks are routed — CLAUDE_CONFIG_DIR means nothing to the other
executors.
Routing hooks also get TASK_EXECUTOR and TASK_CLAUDE_CONFIG_DIR.
Worked example: claude-profile-router in the community collection.
Most events are fire-and-forget: the script runs in the background and nothing
waits for it or reads what it says. task.placement is one of the
exceptions, alongside task.route above and task.hosts below — ty asks it a
question and uses the answer.
Where a task runs has to be decided before the executor spawns, and the
answer has to come back — so this hook is synchronous, bounded by a short
timeout, and its stdout is parsed.
ty writes the request to the handler's stdin:
{"event":"task.placement",
"task":{"id":5228,"title":"Add a consulted task.placement hook",
"project":"taskyou","repo_path":"/Users/you/Projects/workflow",
"executor":"claude","remote_required":false}}and reads the answer from its stdout:
{"target":"ol-agents","workdir":"~/projects/engineering",
"reason":"most free memory of 2 hosts serving offerlab"}target— the SSH destination to run on. Empty means run locally unlessunavailableis true or the project requires remote execution.unavailable— optional boolean: no eligible placement exists. This stops resolution and fails the run visibly; later handlers are not consulted.workdir— the task's directory on that host. A remote path, so a leading~is passed through for the remote shell to expand.reason— why. Always shown to the user (ty show, the task log), so write it to explain a surprising placement without further digging.
ty never learns what a host is. It asks the question, and runs the answer:
an empty target uses the local runner — byte for byte what ty has always done —
and a named target starts the task's tmux session on that host over ssh, in that
directory. Where it ran is recorded on the task and shown by ty show and on the
board, so a result can be traced back to the machine that produced it.
By default, failing to decide where to run falls back to local. With
placement.remote_required: true in .taskyou.yml, missing handlers, errors,
empty decisions and recorded local placements all stop the run before a local
launch. A failed remote preflight never falls back to local.
| Situation | What ty does |
|---|---|
No task.placement plugin installed |
Local. Silent. Nothing is asked, logged or recorded. |
| Handler answers with an empty target | Local, and the reason is recorded so you can see why. |
| Handler is slow (over 5s), crashes, exits non-zero, or writes malformed JSON | Local, logged loudly. A hook in the spawn path must never hang a task. |
| Handler names a host ty cannot reach | The task fails, visibly. It is not quietly run locally — that would put the load straight back on the machine placement exists to unload, on the days you are least likely to notice. |
If several plugins declare task.placement they are consulted in name order and
the first to name a host or return unavailable: true wins; a handler that
answers "local" lets the next one try.
extensions/ty-on is a working resolver: it
reads the on CLI's host inventory and answers
with the host that serves the task's project (picking the one with the most free
memory when several do). Build and install it with:
make install-ty-on # builds the binary and installs it as a plugin
make uninstall-ty-on # every task goes back to running locallytask.placement answers where a task goes. task.hosts answers where it
could go, and it exists so a person can overrule the automatic answer at the
moment they create the task rather than moving it afterwards.
ty asks it while a new-task form is open — in the TUI, in the desktop/browser
GUI, and for ty create --host <tab>. The request is the placement request
without a task id, since nothing has been created yet:
{"event":"task.hosts",
"task":{"project":"taskyou","repo_path":"/Users/you/Projects/workflow",
"executor":"claude"}}and the answer is a list:
{"hosts":[{"name":"ol-agents","target":"ol-agents",
"workdir":"~/projects/engineering","detail":"agent, ruby"}]}name— what the user sees. Defaults totargetwhen omitted.target— the SSH destination ty records if this one is picked. Defaults tonamewhen omitted; an entry with neither is dropped.workdir— that project's directory on that host. ty stores it with the placement, so nobody has to look the path up to choose a machine.detail— optional one-liner shown beside the name.
Unlike placement, every handler is consulted and the answers are merged (duplicate targets keep the first): two fleets installed side by side are two sets of candidates, not a race. A handler that fails, times out (5s) or answers unusably contributes nothing.
An empty list is the normal answer for a project no host serves — and the answer for everyone with no placement plugin at all. The forms then show no host picker, which is exactly how ty looked before this existed.
Picking a host records it as the task's placement decision before it can spawn,
which is the decision the executor already prefers over asking the resolver. So
nothing downstream has to know a human made it, and ty place still moves the
task afterwards, work and all.
ty holds one standing connection per host, not one per task. A small POSIX
shell agent runs on the host, walks every ty window each tick, and streams back a
single snapshot covering all of them, so the cost of watching a fleet is
O(hosts) rather than O(tasks). Before this, each task cost two ssh round
trips per tick — one for its window, one to capture its pane — which is a
thousand ssh spawns every fifteen seconds at a few hundred agents.
The agent is a shell script rather than a ty binary on purpose: it needs no
install, no cross-compilation for the host's architecture, and no version
agreement between the two ends. It requires only tmux.
The direction matters as much as the cost. ty holds the connection outbound, exactly as it does for every other remote command. Nothing listens on your machine, no port is opened, no reverse tunnel exists, and no host is given a way to reach back on its own initiative — the agent speaks only by writing to the stdout of a process ty started.
If the channel cannot speak for a host — none started yet, or its last snapshot is stale — it reports "I don't know" and ty falls back to probing that task directly. Failed enumeration never becomes an empty successful snapshot, and a failed pane capture preserves the fact that its window exists. A stream with no incoming data for 30 seconds is restarted, with reconnect delays of 10, 20, then at most 30 seconds. Host health and its last successful observation are available through the placement API, TUI and desktop/browser detail views.
The taskyou_* MCP tools are stdio, so a remotely placed agent's MCP server
would talk to its own host's database rather than to ty. Instead, ty installs
.ty/signal in the task's worktree and tells the agent about it in the prompt:
.ty/signal done "<one line saying what you did>"
.ty/signal needs-input "<the question you need answered>"
.ty/signal failed "<what stopped you>"The script atomically writes a private file under
~/.ty-events/<coordinator-id>/. Each event carries the task, run and event IDs.
The host streams it until ty persists it in SQLite and acknowledges that exact
file. Duplicate delivery is harmless; previous runs and other hosts cannot
change the current run. Persisted signals survive coordinator restarts. The sentence the agent writes reaches the board
as the task's message — usually the only explanation of how a task on another
machine ended.
Without this ty had to infer completion from the screen: an agent that stopped repainting for two minutes was called finished. That is wrong in both directions — an agent pausing to think looks done, and an agent that finished in six seconds is parked two minutes later labelled "needs input" when nothing was asked. The idle heuristic is still there underneath, for a host where the script could not be installed, and ty says so when it falls back.
- The
claudeandcodexexecutors can be launched remotely. Other executors require local placement. Remote launches start a new agent session; moving carries code and a handoff, not the native agent conversation. - Attachments are staged in the local workspace, so they are not available to a remotely-placed task. A file you drop into a remote agent's pane is a path on your machine, and the agent cannot open it.
- A host must already have a checkout of the project, mapped in the resolver's inventory. ty creates the task's worktree there, but does not clone a project the host has never seen.
Every hook receives the standard task variables:
TASK_ID TASK_TITLE TASK_STATUS TASK_PROJECT TASK_TYPE
TASK_MESSAGE TASK_EVENT WORKTREE_PATH
Plugin hooks additionally receive:
TASK_PLUGIN_NAME # this plugin's name
TASK_PLUGIN_DIR # absolute path to this plugin's directory
The script's working directory is set to the plugin directory, so it can read its own bundled files (config, templates, helper binaries) with relative paths.
Hooks fire automatically on events. Actions are commands the user triggers on
demand. Each action has an id, an optional label, and a command (a script
path relative to the plugin dir).
Run one from the CLI, optionally against a task:
ty plugins run my-plugin sync # no task context
ty plugins run my-plugin sync 42 # with task #42's envAn action script receives TASK_PLUGIN_NAME / TASK_PLUGIN_DIR always, and the
TASK_* variables (TASK_ID, TASK_TITLE, TASK_STATUS, TASK_PROJECT,
TASK_TYPE, WORKTREE_PATH) when a task is in context. Unlike hooks, actions
run synchronously (up to 60s) and their output is shown to the caller (the CLI
prints it; the TUI shows the first line in the notification banner).
Actions are reachable from every surface, all running the same command:
- CLI:
ty plugins run <plugin> <action> [task-id]. - TUI — detail view: press
Aon a task to open a picker of that task's plugin actions; the chosen one runs with the task's env. - TUI — command palette: open it (
p/Ctrl+P) and type a leading>to switch from task search to action search. - GUI / API:
GET /api/plugins/actionslists them;POST /api/plugins/actions/run({plugin, action, task_id?}) runs one. The desktop app and any agent use these.
Two capabilities need no manifest entry — a plugin ships them by convention, just by having the right subdirectory:
workflows/*.yaml— each file is a workflow definition, resolvable byty pipeline -d <name>once the plugin is installed.routines/<name>/prompt.md— each subdirectory is a routine: a named, unattended agent run, resolvable byty run <name>and listed byty routines(frontmatter formodel/project/timeout/… works exactly as it does for a user routine under~/.config/task/routines/).
my-plugin/
├── plugin.yaml
├── workflows/
│ └── review.yaml # ty pipeline -d review "<goal>"
└── routines/
└── linear-poll/
└── prompt.md # ty run linear-poll (+ ty routine schedule --every 2m)
A user routine of the same name shadows a plugin's (your
~/.config/task/routines/ wins), so a plugin routine is a sensible default you can
override. Scheduling stays a separate step (ty routine schedule <name> --every … | --cron …) — the plugin ships the what, you choose the cadence.
Routine vs. service: ship a routine for periodic, run-to-completion work (a
poller, a digest, a nightly sweep) — the daemon records each run, enforces a timeout,
and keeps cross-run state in ROUTINE_STATE_DIR. Ship a service (where available)
only for a process that must stay up (a socket connection, a listening port).
- Fan-out: for each event, the legacy single-script hook and every plugin that declares the event run, concurrently and in the background. A slow or failing plugin never blocks task execution or the other plugins.
- Isolation: a malformed manifest, a missing script, or a plugin with no
name is skipped (surfaced via
ty plugins listand the daemon log) — one bad community plugin can't break your pipeline. - Timeout: each hook is given 30s before it's killed.
- Deterministic order: plugins run sorted by name.
ty plugins list # what's installed and which events each handles
ty plugins dir # the plugins directory pathSet TY_PLUGINS_DIR to use a directory other than ~/.config/task/plugins/.
Complete, copy-pasteable plugins live in examples/plugins/:
| Plugin | Kind | What it shows |
|---|---|---|
desktop-notify |
hooks + action | native notifications; a test action |
slack |
hooks | webhook integration; bundled config.env |
worktree |
actions | task-scoped diff / test using WORKTREE_PATH |
heartbeat |
service | a daemon-supervised long-running process |
cp -R examples/plugins/desktop-notify ~/.config/task/plugins/
ty plugins listFor ready-made, reviewable plugins, see the community collection at github.com/taskyou/plugins (see Installing). For more ideas, see the plugin idea gallery.
The catalog is one JSON document, kept in the main repo at
internal/registry/catalog.json and published
at docs/registry.json → https://taskyou.dev/registry.json. The
two copies must be byte-identical — make sync-registry copies one over the other
and a test fails the build if they drift.
To list a plugin, add an entry and open a PR:
{
"id": "slack",
"name": "Slack notifications",
"description": "Posts task updates to a Slack channel through an incoming webhook.",
"author": "taskyou",
"source": "https://github.com/bborn/taskyou",
"subdir": "examples/plugins/slack",
"homepage": "https://github.com/bborn/taskyou/tree/main/examples/plugins/slack",
"category": "notifications",
"tags": ["notifications", "slack", "chat", "webhook"],
"provides": ["hook"],
"requires": ["a Slack incoming-webhook URL in config.env"]
}| Field | Required | Notes |
|---|---|---|
id |
yes | the handle users type (ty plugins add <id>); unique, and the directory it installs into |
name |
— | human-facing title; defaults to the id |
description |
yes | one or two sentences aimed at someone deciding whether to install |
source |
yes | the git URL to clone |
subdir |
— | the plugin's path inside source, for a collection repo. Omit when the repo root is the plugin |
author, homepage |
— | display only |
category |
— | groups the browse listing (workflows, notifications, routing, development, …) |
tags |
— | free-form search keywords |
provides |
— | workflow, hook, action, service, routine — what you get |
requires |
— | prerequisites in plain words ("jq or python3", "an ARC API key"), shown as a warning before install |
Entries missing id, source, or description are dropped at parse time rather
than shown as broken rows. Nothing is installed automatically: the catalog only
says what can be installed.