# vtriv REST API

The REST API is the stable, ID-first interface for projects, tasks, knowledge,
and workspace metadata. This document is the public contract.

## Base URL and authentication

Base URL: https://app.vtriv.com

Send a human or agent API key as a bearer token on every API request:

~~~http
Authorization: Bearer vtk_your_key_or_vtak_agent_key
Content-Type: application/json
~~~

Human keys start with vtk_; agent keys start with vtak_. Treat either like a
password. A newly generated key is shown once and cannot be recovered; revoke
it in Settings if it is exposed.

This Markdown document and GET /api/health are public. The only other public
routes are secret capability URLs minted by protected endpoints, such as a
temporary attachment download. Ordinary /api routes require authentication. A
missing, revoked, or invalid key gets HTTP 401 with {"error":"Unauthorized"}.

## Start here

Discover projects and their workspace-scoped task fields in one call:

~~~bash
export VTRIV_API_KEY='vtk_...'
curl -s https://app.vtriv.com/api/projects   -H "Authorization: Bearer $VTRIV_API_KEY"
~~~

Projects and tasks have stable UUIDs. Prefer those IDs in API inputs. Slugs are
mutable human aliases retained for browser routes and compatibility.

## Conventions

- JSON request and response bodies use UTF-8. Binary uploads use base64 fields.
- Unix timestamps are integer seconds. Dates are YYYY-MM-DD; times are HH:MM.
- Collections use named envelopes such as {"projects":[...]} and
  {"tasks":[...]}. Singular reads and writes use {"project":{...}} or
  {"task":{...}}. Creates return HTTP 201 with the hydrated resource.
- Deletes return {"deleted":true}.
- Errors are JSON with an error string. Plan-limit errors may also include code
  PLAN_LIMIT and limit. HTTP 400 means invalid input, 403 forbidden or plan
  limited, 404 not found in the caller's scope, and 409 a conflict.
- URL-encode paths and query values.

## Core response objects

Field names below are exact; examples show nullable fields as null.

### Current principal

~~~json
{
  "userId":"user_...", "email":"tim@example.com", "name":"Tim",
  "image_url":null, "plan":"pro", "role":"owner",
  "email_verified":true, "has_password":true,
  "is_agent":false, "agent_of":null, "dispatch_options":null
}
~~~

GET /api/me returns this object directly. For an agent key, is_agent is true,
userId is the agent's own ID, and agent_of is its owner's ID. An agent's
workspace access is derived live from its owner, so removing the owner's access
removes the agent's access too. dispatch_options is the agent's optional runner
manifest; it is not an assignee roster. Use the workspace assignees endpoint
for assignment choices.

### Context

~~~json
{
  "qualifier":"~personal", "label":"Personal", "orgId":null,
  "workspaceId":"019...", "description":null,
  "projectCount":12, "fileCount":40
}
~~~

GET /api/orgs returns {"contexts":[Context]}. GET /api/orgs/:qualifier returns
one Context directly. qualifier is for web navigation; workspaceId is the
stable API scope ID.

### Project summary

~~~json
{
  "id":"019...", "slug":"vtriv", "name":"vtriv",
  "description":"Knowledge and project management", "status":"active",
  "github_url":"https://github.com/example/vtriv", "area_id":null,
  "content_length":1800, "updated_at":1785792000, "pinned":1,
  "todo_count":3, "in_progress_count":1, "done_count":40,
  "urgent_high_count":1, "file_count":16,
  "workspace_id":"019...", "workspace_name":"Personal",
  "workspace_qualifier":"~personal",
  "custom_field_definitions":{"version":1,"fields":[]}
}
~~~

GET /api/projects returns {"projects":[ProjectSummary]}. It fans out over every
workspace the caller may access. Filter with workspace_id=<stable id> and/or
status=active|completed|archived|all. Archived projects are omitted by default.

GET /api/projects/:projectId returns {"project":Project}. Its fields are id,
slug, name, description, status, github_url, area_id, content_hash,
content_length, created_by, updated_by, created_at, updated_at, pinned,
watching, watcher_count, workspace_id, content (Markdown or null), routes,
hydrated tasks, and custom_field_definitions.

### Task

~~~json
{
  "id":"019...", "project_id":"019...", "workspace_id":"019...",
  "project_slug":"vtriv", "project_name":"vtriv",
  "workspace_slug":"personal", "workspace_name":"Personal",
  "title":"Document the API", "description":null,
  "status":"todo", "priority":"high", "type":null,
  "sort_order":1, "due_date":null, "due_time":null, "start_date":null,
  "estimate":null, "assignee":"vamos", "recurrence":null,
  "fields":{"preset":"deep"}, "dispatch":{"preset":"deep"},
  "labels":["docs"], "skills":[], "checklist":[],
  "context":[{"topic":"API reference","when":"before changing endpoints","path":"docs/api.md","anchor":"tasks","title":"API reference","dangling":false}],
  "blocked_by":[], "blocked":false, "comment_count":0,
  "created_by":"user_019...", "updated_by":"user_019...",
  "created_at":1785792000, "updated_at":1785792000,
  "last_activity_at":1785792000
}
~~~

project_id and workspace_id are always present on the ID-first /api/tasks
routes. Cross-workspace task lists also include project_slug and workspace
metadata, which supplies the compatibility comment route's address.
Workspace/project-scoped compatibility routes omit fields already in the URL.

### Assignee

~~~json
{"id":"vamos","display_name":"Vamos","kind":"agent","avatar_url":null}
~~~

GET /api/workspaces/:workspaceId/assignees returns
{"assignees":[Assignee]}. kind is user or agent. The returned IDs are exactly
the values accepted by task writes. "me" is explicit write sugar for the
authenticated principal; it is normalized to that principal's ID. null means
unassigned. Unknown or out-of-scope IDs are rejected instead of creating ghost
assignees.

## ID-first projects and tasks

| Method | Path | Purpose |
|---|---|---|
| GET | /api/me | Current user and plan |
| GET | /api/orgs | List accessible spaces/workspaces |
| GET | /api/orgs/:qualifier | Resolve one space qualifier |
| GET | /api/projects | List accessible projects across workspaces |
| GET | /api/projects/:projectId | Read one project by stable ID |
| POST | /api/tasks | Create a task; body includes project_id |
| GET | /api/tasks | List accessible tasks across workspaces |
| GET | /api/tasks/:taskId | Read one task by stable ID |
| PUT/PATCH | /api/tasks/:taskId | Partial task update |
| DELETE | /api/tasks/:taskId | Delete a task |
| GET | /api/search?q=:query&limit=20 | Search all accessible content |
| GET | /api/recent?limit=10 | Recent projects, files, notes, and tasks |
| GET | /api/board | Cross-workspace task board |
| GET | /api/usage | Current plan usage and limits |

POST /api/tasks accepts project_id, optional client-minted id, and the task
write fields below. It returns {"task":Task}. GET and update return the same
envelope and hydrated shape, so a client does not need a follow-up read.

GET /api/tasks is the bounded cross-workspace list. It accepts status, priority,
assignee, label, project, due_before, due_after, runnable, and limit (default
50, maximum 200). assignee=me resolves to the authenticated principal. The
default status is open (every task not done); runnable=1 selects every not-done
task plus done, agent-assigned tasks with `awaiting_agent` set. An explicit
status wins over runnable. Results are ordered by priority and due date across
every accessible workspace.

Search accepts an optional workspace slug in the workspace query parameter.
Board filters include status, priority, assignee, label, project, workspace,
and done_limit.

## Agent identities and external runners

An agent is an account a human creates for an external runner. There is no
bundled runner. The runner decides whether and how to execute; its schedule,
model, tools, environment, and retry policy are outside vtriv. This API stores
and exposes identity, derived workspace access, task state, comments, and
durable records.

| Method | Path | Purpose/body |
|---|---|---|
| GET | /api/agents | List your agents and key status |
| POST | /api/agents | {"slug":"researcher","name":"Researcher"?} |
| POST | /api/agents/:id/key | Mint the first agent key |
| POST | /api/agents/:id/key/rotate | Replace the active key |
| DELETE | /api/agents/:id/key | Revoke the active key |

Agent slugs are stable global IDs: 2–40 lowercase letters, digits, and hyphens,
beginning and ending with a letter or digit. They are the value used in a task's
assignee field. Only a human principal may call /api/agents. Agent keys receive
HTTP 403 on every management route, including their own. Agent list responses
include key presence, creation, and last-use timestamps but never key material.
The raw vtak_ key is returned only by mint and rotate; store it in the runner's
secret store before closing the response.

Create and provision an agent once with a human session or human key:

~~~bash
curl -s https://app.vtriv.com/api/agents   -H "Authorization: Bearer $VTRIV_HUMAN_KEY"   -H "Content-Type: application/json"   --data '{"slug":"researcher","name":"Researcher"}'

curl -s -X POST https://app.vtriv.com/api/agents/researcher/key   -H "Authorization: Bearer $VTRIV_HUMAN_KEY"
~~~

An external runner can read its own ID instead of hardcoding an assignee, query
the task state it wants to consider, choose a task under its own policy, and
write lifecycle changes:

~~~bash
export VTRIV_AGENT_KEY='vtak_...'
auth=(-H "Authorization: Bearer $VTRIV_AGENT_KEY")

me=$(curl -s "${auth[@]}" https://app.vtriv.com/api/me)
queue=$(curl -s "${auth[@]}"   'https://app.vtriv.com/api/tasks?assignee=me&runnable=1&limit=50')

# The runner selects a task ID, then records its lifecycle.
curl -s -X PATCH "${auth[@]}" -H "Content-Type: application/json"   https://app.vtriv.com/api/tasks/TASK_ID   --data '{"status":"in_progress"}'
curl -s -X PATCH "${auth[@]}" -H "Content-Type: application/json"   https://app.vtriv.com/api/tasks/TASK_ID   --data '{"status":"done"}'
~~~

Task results include workspace_id and project_slug for task comments:

~~~bash
curl -s -X POST "${auth[@]}" -H "Content-Type: application/json"   https://app.vtriv.com/api/workspaces/WORKSPACE_ID/projects/PROJECT_SLUG/tasks/TASK_ID/comments   --data '{"body":"Completed: documented the result and linked the follow-up."}'
~~~

Assign a task by setting its assignee to the agent ID on create or update. The
assignment is accepted only when the agent's owner can access that workspace.
For a follow-up on a done agent task, post a comment with {"reopen":true}; it
sets that task's `awaiting_agent` state. An agent-authenticated comment clears
the state. A runner may include these recorded follow-ups with `runnable=1`.

## Profile and organization brief

| Method | Path | JSON body |
|---|---|---|
| GET | /api/profile | none |
| PUT | /api/profile | {"content":"markdown"} |
| GET | /api/workspaces/:wid/brief | none |
| PUT | /api/workspaces/:wid/brief | {"content":"markdown"} |

Profile is global to the user. Brief belongs to an organization workspace and
can be edited only by an organization owner/admin.

## Import and export

| Method | Path | Body |
|---|---|---|
| GET | /api/export | Downloads the full Markdown projection as a zip |
| POST | /api/import | A zip body, multipart files, or JSON zip_base64 / files |

POST /api/import accepts the export layout (profile.md, skills, workspace
folders, and projects) or arbitrary Markdown paths from a folder. Send
dry_run=true as a query parameter or multipart/JSON field to preview the
ready counts and collisions before it writes. Collisions are skipped. Plan
limits return PLAN_LIMIT with the import progress in imported.

## Workspace routes and compatibility aliases

| Method | Path | Purpose/body |
|---|---|---|
| GET | /api/workspaces/:wid/areas | List areas |
| POST | /api/workspaces/:wid/areas | {name, slug, description?, body?} |
| GET | /api/workspaces/:wid/areas/:slug | Read an area and body |
| PUT | /api/workspaces/:wid/areas/:slug | Partial area fields |
| DELETE | /api/workspaces/:wid/areas/:slug | Delete area; projects remain |
| GET | /api/workspaces/:wid/assignees | Typed assignable users and agents |
| GET | /api/workspaces/:wid/custom-fields | Read task-field definitions |
| PUT | /api/workspaces/:wid/custom-fields | Replace definitions; null clears |
| GET | /api/workspaces/:wid/projects | List projects; optional status query |
| POST | /api/workspaces/:wid/projects | {name, slug, description?, body?, status?, github_url?, copy_from?} |
| GET | /api/workspaces/:wid/projects/:project | Project, body, routes, and tasks |
| PUT | /api/workspaces/:wid/projects/:project | Partial project fields |
| DELETE | /api/workspaces/:wid/projects/:project | Delete project and its children |

The :project segment accepts a stable project ID (preferred) or a slug alias.
Project status is active, completed, or archived. An update may set name, slug,
description, status, body, github_url, or area_id. Set github_url or area_id to
null to clear it. copy_from is a same-workspace source project slug.

### Project-scoped task aliases

| Method | Path | Purpose |
|---|---|---|
| GET | /api/workspaces/:wid/tasks | Query tasks across the workspace |
| GET | /api/workspaces/:wid/projects/:project/tasks | List project tasks |
| POST | /api/workspaces/:wid/projects/:project/tasks | Create; returns {task} |
| GET | /api/workspaces/:wid/projects/:project/tasks/:tid | Read {task} |
| PUT | /api/workspaces/:wid/projects/:project/tasks/:tid | Update; returns {task} |
| DELETE | /api/workspaces/:wid/projects/:project/tasks/:tid | Delete task |

Workspace task query filters: status, priority, assignee, label, project,
due_before, due_after, and runnable=1.

Task write fields are title, description, status, priority, type, sort_order,
due_date, due_time, start_date, estimate, assignee, recurrence, fields, dispatch,
labels, skills, checklist, blocked_by, and context. Unknown fields are rejected.
title is required on create. status is todo, in_progress, or done. priority is urgent,
high, medium, low, or none. recurrence is daily, weekly, monthly, or null.
blocked_by is an array of same-project task IDs. labels is an array of strings.
skills is an array of {"slug":"...","scope":"global|context"}.
context is an ordered array of {"topic":"...","doc":"path.md[#section]","when"?:"..."}; it replaces existing
routes on update. Reads return each route with its current path, anchor, title,
and dangling status so clients can tell when a referenced document is missing.

Example:

~~~bash
curl -s https://app.vtriv.com/api/workspaces/WORKSPACE_ID/projects/vtriv/tasks   -H "Authorization: Bearer $VTRIV_API_KEY"   -H "Content-Type: application/json"   --data '{"title":"Document the API","priority":"high","labels":["docs"]}'
~~~

### Custom fields

Workspaces may declare generic select fields for tasks. The definitions and
selected task values are available through the REST API:

| Method | Path | Purpose/body |
|---|---|---|
| GET | /api/workspaces/:wid/custom-fields | Read {"definitions":object|null} |
| PUT | /api/workspaces/:wid/custom-fields | {"definitions":object|null}; null clears |

A definition has a positive integer version and a fields array. Each field has
a key and type=select:

- label is the display label; group is a display-only section label.
- options is a fixed list. When present, it takes precedence over options_by.
- options_by maps a parent selection to this field's choices.
- options_from names the parent field for options_by/defaults_by. When omitted,
  the preceding field is the parent.
- default is the fixed fallback. defaults_by maps a parent selection to a
  fallback. Resolution order is stored value, default, defaults_by[parent],
  then the first available option.

Example:

~~~json
{"version":1,"fields":[{"key":"backend","type":"select","options":["codex","claude"]},{"key":"model","type":"select","group":"Dispatch","options_from":"backend","options_by":{"codex":["gpt-5.6"],"claude":["opus"]},"defaults_by":{"codex":"gpt-5.6","claude":"opus"}}]}
~~~

Set selections on a task create or update with a flat string map:

~~~json
{"fields":{"preset":"deep"}}
~~~

Task reads return the same map as fields, or null when unset. On update, omit
fields to preserve the current values, pass null to clear them, or pass a map to
replace them. Values are shape-checked but not constrained to the current
definitions. vtriv stores them as uninterpreted task data, so callers can
survive definition changes without data loss.
dispatch is a deprecated compatibility alias for fields. It was deprecated on
2026-08-04 and will be removed no earlier than 2026-11-04. During this window,
writes accept either name (fields wins when both are present) and reads emit
both with identical values. New clients must use fields.

### Checklist and comments

| Method | Path | JSON body |
|---|---|---|
| POST | /api/workspaces/:wid/projects/:slug/tasks/:tid/checklist | {"text":"..."} |
| PUT | /api/workspaces/:wid/projects/:slug/tasks/:tid/checklist/:itemId | {"text"?,"done"?} |
| DELETE | /api/workspaces/:wid/projects/:slug/tasks/:tid/checklist/:itemId | none |
| GET | /api/workspaces/:wid/projects/:slug/tasks/:tid/comments | none |
| POST | /api/workspaces/:wid/projects/:slug/tasks/:tid/comments | {"body":"markdown","reopen"?:true} |
| PUT | /api/workspaces/:wid/projects/:slug/tasks/:tid/comments/:commentId | {"body":"markdown"} |
| DELETE | /api/workspaces/:wid/projects/:slug/tasks/:tid/comments/:commentId | none |

Comments can be edited or deleted only by their author. For an agent-assigned
task, a human comment sets its `awaiting_agent` follow-up state automatically;
passing reopen:true sets it on any caller. An agent-authenticated comment clears
that state. External runners decide whether to include it in their task query.

## Knowledge files

| Method | Path | Purpose/body |
|---|---|---|
| GET | /api/workspaces/:wid/files?limit=50 | List files |
| POST | /api/workspaces/:wid/files | {path, content or content_base64, title?, message?, project_slug?} |
| GET | /api/workspaces/:wid/files/:path | Read file content |
| PUT | /api/workspaces/:wid/files/:path | Replace content; accepts content or content_base64 |
| PATCH | /api/workspaces/:wid/files/:path | {"archived":true|false} |
| DELETE | /api/workspaces/:wid/files/:path | Delete file |

List filters include project=:slug, project=_global, and include_archived=1.
When project_slug is supplied on create, vtriv projects the file beneath that
project's files tree.

## Notes and project timeline

| Method | Path | Purpose/body |
|---|---|---|
| GET | /api/workspaces/:wid/projects/:slug/notes | List notes |
| POST | /api/workspaces/:wid/projects/:slug/notes | {title?, content or content_base64} |
| GET | /api/workspaces/:wid/notes/:noteId | Read note |
| PUT | /api/workspaces/:wid/notes/:noteId | Replace note content/title |
| DELETE | /api/workspaces/:wid/notes/:noteId | Delete note |
| GET | /api/workspaces/:wid/projects/:slug/timeline | List activity; limit and before queries |
| POST | /api/workspaces/:wid/projects/:slug/timeline | {summary, detail?} milestone |
| DELETE | /api/workspaces/:wid/projects/:slug/timeline/:entryId | Delete timeline entry |

## Attachments

Project uploads use POST /api/workspaces/:wid/projects/:slug/attachments with
{filename, content_base64, content_type?}. Task uploads use POST
/api/workspaces/:wid/projects/:slug/tasks/:tid/attachments with the same fields
plus optional comment_id. List them with GET on the same collection path.

Download project bytes at /api/workspaces/:wid/attachments/:attachmentId and
task bytes at /api/workspaces/:wid/task-attachments/:id. POST the same path with
/link appended to mint a temporary unauthenticated download URL. Delete the
attachment with DELETE on its download path.

## Skills

Global skills use /api/skills. Context skills use
/api/workspaces/:wid/skills. Both support:

| Method | Suffix | Purpose/body |
|---|---|---|
| GET | / | List skills |
| POST | / | Create/replace from {"files":[...]} |
| GET | /:slug | Read skill metadata and files |
| DELETE | /:slug | Delete skill |
| GET | /:slug/files/:path | Read a supporting file |
| PUT | /:slug/files/:path | {content or content_base64, content_type?} |
| DELETE | /:slug/files/:path | Delete supporting file |

A skill requires SKILL.md. File entries use path, content, optional content_type,
and optional encoding=base64.

