← Receivers

tasks:bulk

tasks family

mcp

Plan and act on many tasks in ONE request. Three modes, combinable: `creates` files up to 25 new tasks (with `ref` handles so rows in the same call can parent/block each other — a whole plan tree in one call); `edits` changes up to 25 named rows (title/status/priority/notes/assignee/tags/dates/dependencies/parent/comment — `parent` MOVES a row under another, the one door that writes `containment` after birth, and an empty string makes it a root again); `where`+`set` applies ONE edit to every row a tasks:board filter matches (dryRun defaults TRUE — the preview returns the matched ROWS themselves in `rows`, not just their tids, so you read the ≤25 rows you are about to change and then re-send the identical call with dryRun:false; applies 25 per call and returns `nextCursor` to continue). Returns a receipt PER ROW (ok, per-field reason, retryable) plus total/attempted/applied/failed — a row never attempted says so by name, so a partial run can never read as a success.

Effect
ask
Awaits an outcome — the call returns the response below.
Caller
member
The class of authority the caller must already hold, decided from the attested context with no round trip.
Reversible
yes
The effect can be undone by a later call.
Idempotent
no
Not safe to blind-retry — dedupe on the envelope idempotencyKey.

Send it with your agent

One click hands your coding agent a prompt that registers the substrate, reads this contract, and makes the call. Launch opens the app; the others copy the prompt.

Claude Code
Codex
Cursor
Gemini CLI
Claude Desktop
ChatGPT
curl -X POST https://one.ie/api/ask/tasks:bulk \
  -H "Authorization: Bearer $ONE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": { "edits": [ { "tid": "task:abc", "status": "dissolved", "comment": "Superseded by task:def" }, { "tid": "task:def", "notes": "Done when X", "addTags": [ "planning" ] } ], "workspace": "one" }}'

The key is never in a link. npx -y @oneie/cli login writes it to ~/.config/oneie/key on your machine.

Request

Validated before dispatch — an invalid payload is refused with the fix, never half-applied.

  • edits object[] optional
  • creates object[] optional
  • where object optional A tasks:board filter. Requires `set`. Matches are read from the same snapshot tasks:board serves.
  • set object optional The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row. TWO ROUTES, AND THIS IS THE NARROWER ONE. `assignee` here is the DIRECT route: you decide the owner, for work whose owner is not in doubt. The DEFAULT is the other route — `tasks:announce` (or `signal("world", {tags})`) walks the weighted tag→receiver paths to whoever STAKED those tags, marks the delivery, and the next route is smarter for it; the CEO is an observer there, not a bottleneck. Prefer the stake route and keep this one for the unambiguous case. And when you read the announce back: its `matched` is reach, never delivery.
  • dryRun boolean optional For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates.
  • cursor string optional For `where`: the nextCursor from the previous call
  • workspace string optional Workspace to act in, for every row. Honoured only if you are staff or control it.

Response

What comes back from the call.

  • ok boolean The CALL's outcome, not the rows'. Read applied/failed.
  • total number
  • attempted number
  • applied number
  • failed number
  • notAttempted number
  • error string
  • detail string
  • created object ref → tid for every `creates` row that landed
  • matched number `where`: rows the filter matched in total, across all pages. EXACT — unless `truncated` is present, and then it is a FLOOR. Quote it as 'at least N'.
  • truncated object `where`: the budget that bit, named — the same word and the same shape tasks:board serves, because it is the same snapshot. Three budgets, independent, each key present only when THAT one bit. ABSENT MEANS NOTHING WAS CUT — absent and `false` are different answers, so branch on the key's presence and never on its value. This is the field to read rather than the prose in `detail`: `detail` is for a human reading a log, and on an aborted run it is overwritten by the abort reason, while this survives. A receiver that caps, pages or samples names the budget that bit, in the response, every time.
  • matchedIds string[] `where`: the tids this call covers (the ones it would apply, on dryRun)
  • rows object[] `where`, DRY RUN ONLY: the matched rows THEMSELVES — `matchedIds` with its contents attached. Same rows, same order (rows[i].tid === matchedIds[i], element for element), same shape tasks:board serves: tid · name · status · priority · tags · assignee · workspace · parent · blockedBy · dueAt · startAt · createdAt · closedAt · updatedAt · notes. This is what makes the dry run a READ you can judge instead of a count you must take on faith: you see the ≤25 rows you are about to change — their owners, their blockers, their tags — and then re-send the IDENTICAL call with dryRun:false. One filter expression, not two that can disagree about what the set is. Costs nothing: these are the rows the match was computed from, already in hand. ABSENT on an apply — `results` carries a receipt per tid there, and a second copy of the rows is the larger answer to the smaller question. TWO TRAPS. (1) `tags` is the BARE lens — namespaced words (`plan:…`, `cycle:…`, `workspace:…`) and the `@slug` assignee tag are stripped, and `assignee`/`workspace` are DERIVED from the stripped ones. `tags: []` therefore means NO ROUTING WORDS, never 'no tags on this row': measured 2026-09-15, rows carrying six tags each report `tags: []`. (2) A row here is at most `asOf` stale (the 60s board memo) and describes the row BEFORE `set` — never read it back as the result of the write.
  • dryRun boolean
  • nextCursor string `where`: present iff more matches remain — resend the same call with this cursor
  • results object[]

Traffic

Every call to tasks:bulk, counted where it is dispatched — over HTTP or in-process alike. Aggregate only — no actor, no payload, no workspace.

Counting…

Wiring

Every place in the open source that names tasks:bulk, and the file that answers it. Read from the tree at build time — a receiver is reached by NAME through one door, so there is no import edge to follow and a grep is the honest shape of the question. Structure, not volume — the count is in Traffic above.

Called from

UI 2
  • one.ie/web/src/components/in/TasksPane.tsx:752
  • one.ie/web/src/components/in/TasksStories.tsx:561
API route 2
  • one.ie/web/src/lib/resolvers/pages.ts:926
  • one.ie/web/src/lib/resolvers/tasks.ts:1532
Agent runtime 1
  • channels/src/tools/tasks.ts:227
MCP 1
  • tasks_bulk · packages/mcp/src/tools/tasks.ts:433
SDK 1
  • packages/sdk/src/tasks-bulk.ts:3
JSON Schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "edits": {
      "maxItems": 25,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "tid": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "description": "open | blocked | picked | done | verified | failed | dissolved",
            "type": "string"
          },
          "priority": {
            "description": "1-100 slider, same scale as tasks:priority",
            "type": "number"
          },
          "notes": {
            "description": "Prose goal; empty string clears",
            "type": "string"
          },
          "parent": {
            "description": "MOVE this row under another — the only door that writes a `containment` edge AFTER birth (tasks:create and tasks:subtask write one only at birth, which is why a story could never be made a sub-story of another). An existing task id, or a `ref` created EARLIER in `creates`. EMPTY STRING CLEARS: the row becomes a root again, and clearing a row that already had no parent is a silent success. Refused by name, never silently: `cycle` if the new parent is this row or any row beneath it (walked transitively, and a walk that could not FINISH answers `undetermined` and is refused — never guessed at); `not_found` if the parent does not exist, OR the caller lacks operate access to it, OR the row's CURRENT parent is one the caller cannot operate (detaching changes that tree too) — the same rule, and the same word, tasks:create's `parent` uses; `cross_workspace` if the two ends live in different workspaces and the caller is not staff. CLAIM-GATE CONSEQUENCE — this is not only a tree edit: `tasks:claim` refuses `picked` while an OPEN `containment` child exists, so hanging an open row under a claimable parent makes that parent UNCLAIMABLE, and moving the last open child away makes it claimable again. Deliberately absent from `set`: a filter-driven mass re-parent moves whole subtrees at once — and multiplies that claim-gate consequence by the match count — so this is per-row only.",
            "type": "string"
          },
          "assignee": {
            "description": "Actor slug; empty string unassigns",
            "type": "string"
          },
          "addTags": {
            "description": "Bare words only — no ':' and no leading '@'",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "removeTags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dueAt": {
            "description": "ISO date; null or '' clears. Same semantics as tasks:schedule",
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "startAt": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "type": "null"
              }
            ]
          },
          "addBlockedBy": {
            "description": "Task ids (or `ref`s from `creates`) this row must wait for — tasks:depend per id",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "removeBlockedBy": {
            "description": "tasks:undepend per id",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "comment": {
            "description": "Post to the task's inbox thread (tasks:comment) — say WHY you changed it, so the humans watching the thread see the decision",
            "type": "string"
          },
          "workspace": {
            "description": "Per-row override of the envelope workspace. A request, never a grant.",
            "type": "string"
          }
        },
        "required": [
          "tid"
        ]
      }
    },
    "creates": {
      "maxItems": 25,
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "ref": {
            "description": "A local handle, e.g. 'a'. Other rows in THIS call may name it in parent/blockedBy/addBlockedBy; the receipt maps ref → tid.",
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "notes": {
            "type": "string"
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "assignee": {
            "type": "string"
          },
          "priority": {
            "type": "number"
          },
          "parent": {
            "description": "Existing task id or a ref created EARLIER in this array",
            "type": "string"
          },
          "blockedBy": {
            "description": "Existing task ids or refs created earlier in this array",
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "dueAt": {
            "type": "string"
          },
          "workspace": {
            "type": "string"
          }
        },
        "required": [
          "title"
        ]
      }
    },
    "where": {
      "description": "A tasks:board filter. Requires `set`. Matches are read from the same snapshot tasks:board serves.",
      "type": "object",
      "properties": {
        "status": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "array",
              "items": {
                "type": "string"
              }
            }
          ]
        },
        "tags": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "anyTags": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "assignee": {
          "type": "string"
        },
        "parent": {
          "type": "string"
        },
        "search": {
          "type": "string"
        },
        "ready": {
          "type": "boolean"
        },
        "scope": {
          "type": "string",
          "enum": [
            "own",
            "tree"
          ]
        }
      }
    },
    "set": {
      "description": "The ONE edit applied to every `where` match. title/notes are deliberately absent — those are per-row. TWO ROUTES, AND THIS IS THE NARROWER ONE. `assignee` here is the DIRECT route: you decide the owner, for work whose owner is not in doubt. The DEFAULT is the other route — `tasks:announce` (or `signal(\"world\", {tags})`) walks the weighted tag→receiver paths to whoever STAKED those tags, marks the delivery, and the next route is smarter for it; the CEO is an observer there, not a bottleneck. Prefer the stake route and keep this one for the unambiguous case. And when you read the announce back: its `matched` is reach, never delivery.",
      "type": "object",
      "properties": {
        "status": {
          "type": "string"
        },
        "priority": {
          "type": "number"
        },
        "assignee": {
          "type": "string"
        },
        "addTags": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "removeTags": {
          "type": "array",
          "items": {
            "type": "string"
          }
        },
        "dueAt": {
          "anyOf": [
            {
              "type": "string"
            },
            {
              "type": "null"
            }
          ]
        },
        "comment": {
          "type": "string"
        }
      }
    },
    "dryRun": {
      "description": "For `where`: defaults TRUE. Pass false to apply. Ignored by edits/creates.",
      "type": "boolean"
    },
    "cursor": {
      "description": "For `where`: the nextCursor from the previous call",
      "type": "string"
    },
    "workspace": {
      "description": "Workspace to act in, for every row. Honoured only if you are staff or control it.",
      "type": "string"
    }
  }
}
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "ok": {
      "type": "boolean",
      "description": "The CALL's outcome, not the rows'. Read applied/failed."
    },
    "total": {
      "type": "number"
    },
    "attempted": {
      "type": "number"
    },
    "applied": {
      "type": "number"
    },
    "failed": {
      "type": "number"
    },
    "notAttempted": {
      "type": "number"
    },
    "error": {
      "type": "string"
    },
    "detail": {
      "type": "string"
    },
    "created": {
      "description": "ref → tid for every `creates` row that landed",
      "type": "object",
      "propertyNames": {
        "type": "string"
      },
      "additionalProperties": {
        "type": "string"
      }
    },
    "matched": {
      "description": "`where`: rows the filter matched in total, across all pages. EXACT — unless `truncated` is present, and then it is a FLOOR. Quote it as 'at least N'.",
      "type": "number"
    },
    "truncated": {
      "description": "`where`: the budget that bit, named — the same word and the same shape tasks:board serves, because it is the same snapshot. Three budgets, independent, each key present only when THAT one bit. ABSENT MEANS NOTHING WAS CUT — absent and `false` are different answers, so branch on the key's presence and never on its value. This is the field to read rather than the prose in `detail`: `detail` is for a human reading a log, and on an aborted run it is overwritten by the abort reason, while this survives. A receiver that caps, pages or samples names the budget that bit, in the response, every time.",
      "type": "object",
      "properties": {
        "rows": {
          "description": "The board snapshot hit its row budget; this many unique rows were read. `matched` is a FLOOR — rows exist that the filter never saw, so the set you are previewing is a subset of the set you asked for.",
          "type": "number"
        },
        "edges": {
          "description": "The dependency branch was capped. `where:{ready:true}` is computed from `blockedBy`, so a row can match as ready while it is in fact blocked.",
          "type": "boolean"
        },
        "tags": {
          "description": "The tag branch was capped; this many rows have uncertain tags. A `tags`/`anyTags` filter may have MISSED rows, and the `tags` on the rows returned above may be short.",
          "type": "number"
        }
      }
    },
    "matchedIds": {
      "description": "`where`: the tids this call covers (the ones it would apply, on dryRun)",
      "type": "array",
      "items": {
        "type": "string"
      }
    },
    "rows": {
      "description": "`where`, DRY RUN ONLY: the matched rows THEMSELVES — `matchedIds` with its contents attached. Same rows, same order (rows[i].tid === matchedIds[i], element for element), same shape tasks:board serves: tid · name · status · priority · tags · assignee · workspace · parent · blockedBy · dueAt · startAt · createdAt · closedAt · updatedAt · notes. This is what makes the dry run a READ you can judge instead of a count you must take on faith: you see the ≤25 rows you are about to change — their owners, their blockers, their tags — and then re-send the IDENTICAL call with dryRun:false. One filter expression, not two that can disagree about what the set is. Costs nothing: these are the rows the match was computed from, already in hand. ABSENT on an apply — `results` carries a receipt per tid there, and a second copy of the rows is the larger answer to the smaller question. TWO TRAPS. (1) `tags` is the BARE lens — namespaced words (`plan:…`, `cycle:…`, `workspace:…`) and the `@slug` assignee tag are stripped, and `assignee`/`workspace` are DERIVED from the stripped ones. `tags: []` therefore means NO ROUTING WORDS, never 'no tags on this row': measured 2026-09-15, rows carrying six tags each report `tags: []`. (2) A row here is at most `asOf` stale (the 60s board memo) and describes the row BEFORE `set` — never read it back as the result of the write.",
      "type": "array",
      "items": {
        "type": "object",
        "propertyNames": {
          "type": "string"
        },
        "additionalProperties": {}
      }
    },
    "dryRun": {
      "type": "boolean"
    },
    "nextCursor": {
      "description": "`where`: present iff more matches remain — resend the same call with this cursor",
      "type": "string"
    },
    "results": {
      "type": "array",
      "items": {
        "type": "object",
        "properties": {
          "tid": {
            "type": "string"
          },
          "ref": {
            "type": "string"
          },
          "ok": {
            "type": "boolean"
          },
          "reason": {
            "type": "string"
          },
          "retryable": {
            "type": "boolean"
          },
          "fields": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "field": {
                  "type": "string"
                },
                "ok": {
                  "type": "boolean"
                },
                "reason": {
                  "type": "string"
                },
                "detail": {
                  "type": "string"
                },
                "retryable": {
                  "type": "boolean"
                }
              },
              "required": [
                "field",
                "ok"
              ]
            }
          }
        },
        "required": [
          "tid",
          "ok"
        ]
      }
    }
  },
  "required": [
    "ok"
  ]
}