Hub API Guide

The canonical reference for what the Hub API can do and how to use it — for human developers, AI knowledge systems, and software tooling alike.

Give This to Your AI

Hub is designed to work with AI assistants, and this guide is the canonical reference describing how Hub's API actually works. If you use an AI assistant — ChatGPT, Claude, Gemini, a custom GPT, or another AI that can read documentation — giving it this guide helps it work with Hub correctly instead of guessing.

Providing this documentation to an AI helps it:

This guide does not, by itself, do any of the following:

Authorization is still controlled entirely by API keys, sessions, permissions, company (tenant) isolation, and module entitlements — reading this guide is harmless on its own, and teaches an AI how Hub works, not what it's allowed to do. What it's allowed to do is determined by whatever credentials and scope it's actually given.

Connecting Your AI

For an AI to work in Hub, it needs three things. Each does a different job:

What it is What it gives the AI
The guide This page, or api-guide.md Knowledge: how Hub works and which calls exist
The key Your Hub API key, from your welcome email Permission: which company's data it can read and change
The connection A shell, an Action, or a connector that sends HTTP requests with the key attached The ability to act: actually calling Hub

The guide on its own lets an AI explain Hub and draft calls, but it can't make any. Without a connection, the key does nothing. An AI needs all three before it can act on Hub.

The key goes in the tool's auth settings, never into a chat. Put it in an environment variable, a secret store, or the Action's or connector's authentication field. Don't paste it into a conversation, prompt, or shared document. The key in your welcome email is full-scope: it can read, change, and delete your company's data. Only give it to a tool you trust.

AI-led setup: tell your AI "Read https://justwhatsneeded.com/connect-ai and help me connect you to my Hub account." That page is written for the AI. It works out which kind of AI it is and walks you through the steps below.

Code agents with a shell

Claude Code, Codex, Cursor, and similar agents that can run commands only need the guide and the key. Store the key in an environment variable named HUB_API_KEY and have the agent read it from there. Never write the key into a command.

Save the key once, without showing it. Paste one of these into your own terminal window, not into the agent. It asks for the key without showing it on screen, saves it where only you can read it, and makes it available as $HUB_API_KEY in every new terminal window.

Mac / Linux (zsh, the Mac default):

mkdir -p ~/.config/hub && read -rsp "Paste your Hub API key (it won't show): " k && echo && (umask 077; printf '%s' "$k" > ~/.config/hub/api-key) && unset k && { grep -qs HUB_API_KEY ~/.zshrc || echo 'export HUB_API_KEY="$(cat ~/.config/hub/api-key 2>/dev/null)"' >> ~/.zshrc; } && echo "Saved. Open a new terminal window."

If you use bash, replace both ~/.zshrc references with ~/.bashrc. Your agent can tell you which shell you use.

Windows PowerShell:

$k = Read-Host "Paste your Hub API key (it won't show)" -AsSecureString; [Environment]::SetEnvironmentVariable("HUB_API_KEY", [Net.NetworkCredential]::new("", $k).Password, "User"); Remove-Variable k; "Saved. Open a new terminal window."

Then open a new terminal window and start the agent from it. A .env file the agent loads, or the tool's own secret settings, also work.

Rules for the agent:

Test the connection, reading the key from the environment:

curl -s -H "X-API-Key: $HUB_API_KEY" \
  "https://hub.justwhatsneeded.com/api.php?resource=identity&action=whoami"

A successful call returns your company and the key's scope. That proves the connection works.

ChatGPT: Custom GPT Action

Create a Custom GPT, then add an Action:

The full spec is large and includes Internal operations. If ChatGPT rejects or truncates it, import a trimmed copy with just the operations you need, such as tasks, projects, comments, search, and identity. For the clicks, see OpenAI's own docs: GPT Actions.

Knowledge-only AIs

Gemini Gems, Copilot knowledge, and Claude or ChatGPT projects without an Action or connector can read this guide. They can explain Hub and draft API calls for you to run. They cannot act on Hub. They have no way to send an authenticated request, so don't give them your key.

Claude

A Claude connector for Hub customers is coming. Until then, Claude Code can connect as a code agent, and Claude chat can use this guide as knowledge only.

Choose the Format That Fits Your AI

This guide is published in three forms, generated from one canonical source so they can't drift apart. Pick whichever matches how your AI (or tooling) actually consumes documentation.

Humans

Read this HTML guide: https://justwhatsneeded.com/api-guide.html. Formatted for browsers, with the same content as the other two formats plus page styling and navigation.

AI Knowledge

A plain-Markdown version of this exact guide is published at:

https://justwhatsneeded.com/api-guide.md

This is the form to use for:

Software / Tools

The detailed machine contract — every field, schema, and response shape — is the OpenAPI specification:

https://hub.justwhatsneeded.com/docs/openapi.yaml

This is the form to use for:

Example Instructions for Your AI

Two copy-ready examples — pick whichever matches how you're giving your AI this guide.

If your AI can retrieve public web pages:

Use the Hub API Guide at https://justwhatsneeded.com/api-guide.html as the canonical reference for Hub API capabilities, routing conventions, authentication requirements, parameters, responses, and usage guidance. Consult it before assuming an endpoint exists.

If you're uploading this guide as knowledge (Custom GPT, Claude Project, Gemini Gem, etc.):

Use the Hub API Guide at https://justwhatsneeded.com/api-guide.md as your primary knowledge source for Hub API behavior. Prefer documented search capabilities when locating unknown records and direct resource endpoints once IDs are known.

Not every AI can open a URL on its own — it needs browsing, web retrieval, a connector, or a configured action to do that. If yours can't, use the second example and actually upload or paste the Markdown version into its knowledge configuration instead of just handing it the link.

Document Metadata

Title Hub API Guide
Status Canonical
Audience Human developers, AI knowledge systems, and software/tooling
API Guide version 3.4
OpenAPI spec version 2.1.0
API versioning The Hub HTTP API itself is unversioned — no /v1/ path, no version header. The OpenAPI number above is the version of the spec document, not of the API.
Last updated 2026-11-03
Canonical URL (HTML) https://justwhatsneeded.com/api-guide.html
Markdown / AI knowledge URL https://justwhatsneeded.com/api-guide.md
OpenAPI specification https://hub.justwhatsneeded.com/docs/openapi.yaml
Purpose Authoritative instructions for discovering and using supported Hub API capabilities.

AI Agent Guidance

Contents

Authentication

API key (recommended for developers and AI agents). Send your API key in the X-API-Key header on every request. Keys are scoped to one company — a request can only read and write that company's own data.

X-API-Key: your-api-key-here
Content-Type: application/json

Browser session (Hub's own web UI). A logged-in Hub browser session (a valid PHPSESSID cookie) is also accepted and behaves identically to a full-scope API key. This exists for Hub's own first-party pages, not third-party integrations — external developers and AI agents should use an API key instead.

API key scopes. A company's own key is always full-scope. Hub also supports narrower, purpose-specific scoped keys, but customers can't create them themselves yet; self-service scoped keys are planned. Until then, the full-scope key from your welcome email is your company's key. A scoped key can only perform the exact resource/action pairs its scope allows — anything else is rejected with 401 and the same {"error": "Invalid or inactive API key"} response as an invalid key (never 403), so a caller can never distinguish "wrong scope" from "no key at all."

Scope Allowed
full Every resource and action (the company's own key, or a browser session).
ai_agent Intended for external AI integrations and the Hub MCP adapter. No deletes anywhere. Covers: projects list/get/create; tasks list/get/create/update/complete/reopen; comments list/create; ideas list/get/create/update/promote; search; documents search/get/create/update/move/list_by_type; folders create/move; document_attachments list/download; activity_events create/list/get; identity whoami; timezone get/now/format; most read/write actions on relationships (Connections and Who). It also reaches Hub's own AI-orchestration resources (AI briefs, dispatch, the AI pickup queue, notifications, work sessions, email drafts, schedules and similar). The OpenAPI specification documents those, mostly under its Internal tag; this guide doesn't. The authoritative per-operation list is the "Scopes" line on each operation in the OpenAPI specification.
quick_capture tasks&action=create only. Intended for lightweight, single-purpose capture tools.

Hub also uses a few internal scopes for its own background processes; they can't be issued to customers and aren't covered here.

Suspended companies. If a company is suspended, every one of its API keys (full or scoped) and every existing browser session is rejected with 401 on the very next request — the same response as an invalid key or no session, so suspension is never disclosed.

Base URL: https://hub.justwhatsneeded.com/api.php — this is the only public base URL. Every canonical_url Hub returns uses it.

Your company's full-scope API key is shown in the welcome email and on the Admin panel. Keep it private — it grants full read/write access to your company's data. Never paste a real key into a prompt, log, or shared document.

Routing

Hub's API is resource/action routed, not REST-path routed. Every request targets /api.php with two query-string parameters:

Param Meaning
resource The noun being operated on — e.g. tasks, projects, ideas, comments, search.
action The verb — e.g. list, get, create, update, complete, promote, search.

There is no path-templated equivalent like /tasks/42 — every operation is /api.php?resource=...&action=..., with GET query parameters or a JSON POST body carrying the rest of the request. GET is used for read operations, POST for anything that writes. Responses are always JSON.

resource and action must be in the URL query string, never in the JSON body — a request without resource in the query string fails with 400. With a full-scope key or session, an unknown resource returns 404 ("Unknown resource") and an unknown action on a known resource returns 400 ("Unknown action"). A scoped key gets 401 for both, because they fall outside its scope.

This applies uniformly, including to search: it is ?resource=search&action=search, not a bare ?action=search — the resource/action pair is always required, even when a resource has only one action.

Projects

All project actions require the projects module to be enabled for the company (otherwise 403) and only ever touch the caller's own company's projects.

List Projects

GET /api.php?resource=projects&action=list — scope: full, ai_agent

Purpose: List the caller's company's projects, most recently updated first.
When to use: Browsing or picking a project when you don't yet know its ID.
When not to use: Don't page through this to find one task or record — use search instead.
Optional parameters: show_all (boolean) — include hidden (display=0) projects. Default false. archived (boolean) — return archived projects instead of active ones. Default false (archived projects are excluded).

Example response

[
  {
    "id": 11,
    "company_id": 1,
    "name": "JWN - All",
    "description": "Main project board",
    "status": "active",
    "display": 1,
    "client": "JWN",
    "archived_at": null,
    "created_at": "2025-01-10 09:00:00",
    "updated_at": "2026-06-01 14:22:00",
    "url": "/project.php?id=11",
    "canonical_url": "https://hub.justwhatsneeded.com/project.php?id=11"
  }
]

Get Project

GET /api.php?resource=projects&action=get&id=11 — scope: full, ai_agent

Purpose: Read one project row (same shape as one list item). 404 if it doesn't exist or belongs to another company.

Get Project with Context

GET /api.php?resource=projects&action=get-with-context&id=11 — scope: full

Purpose: The project row plus all of its tasks (tasks, not paginated) and up to 10 historical rows from the retired interactions log (interactions, usually empty).

Create Project

POST /api.php?resource=projects&action=create — scope: full, ai_agent

Purpose: Create a new project — or return the existing one if the name is already taken.
Duplicate handling: If the company already has a non-archived project whose name matches case-insensitively (after trimming), that project is returned with "duplicate": true and status 200, and nothing new is created. A genuinely new project returns 201 with "duplicate": false. Safe to retry.
Tenant isolation: Created under the caller's own company automatically — there is no company_id field to set.

Field Type Notes
name (required) string Project name, max 255 characters
description string Optional description
status string active · on-hold · completed · archived (default: active). Anything else → 400.
client string Client label, e.g. JWN, max 255 characters

Example request

{ "name": "Q3 Marketing Campaign", "description": "All tasks for the Q3 push", "status": "active", "client": "JWN" }

Example response (201)

{ "id": 14, "company_id": 1, "name": "Q3 Marketing Campaign", "status": "active", "client": "JWN", "archived_at": null, "duplicate": false, "url": "/project.php?id=14", "canonical_url": "https://hub.justwhatsneeded.com/project.php?id=14" }

Update, Hide, Archive, Unarchive, Delete Project

All POST, scope: full only — not available to ai_agent keys.

Action Body Effect / response
update id (required), any of name, description, status, client Changes only the fields sent. {"message": "Project updated"}. An id outside your company changes nothing.
hide id (required), display (boolean, default false) Hides (display=0) or shows the project in default lists. {"message": "Updated", "display": 0}
archive id (required) Sets archived_at; returns the project row. Idempotent. 404 for an unknown/foreign id.
unarchive id (required) Clears archived_at; returns the project row. Idempotent.
delete id (required) Permanent. Deletes the project, all of its tasks, and those tasks' comments. {"message": "Project deleted"}

Tasks

All task actions require the tasks module (otherwise 403) and only ever reach tasks whose project belongs to the caller's company.

List Tasks

GET /api.php?resource=tasks&action=list&project_id=11 — scope: full, ai_agent

Purpose: List tasks, optionally filtered to one project or one category.
When to use: You already know the project (or category) and want every task in it.
When not to use: Looking for one specific task by title or keyword across many projects — use search instead of listing and scanning client-side.
Optional parameters: project_id (integer) — restrict to one project. category (string) — restrict to a category; when set, done tasks are excluded unless include_done is also set. include_done (boolean) — only relevant alongside category. Without category, done tasks are always included.
Validation: No pagination on this endpoint — it returns every matching row. For large result sets or keyword filtering, prefer search, which supports limit/offset.

Example response

[
  {
    "id": 42,
    "project_id": 11,
    "title": "Review contract",
    "description": null,
    "tags": null,
    "status": "todo",
    "priority": "high",
    "category": null,
    "due_date": "2026-06-20",
    "delivery_mode": "commit_push",
    "version": 1,
    "created_at": "2026-06-10 08:00:00",
    "updated_at": "2026-06-10 08:00:00",
    "url": "/project.php?id=11&task=42",
    "canonical_url": "https://hub.justwhatsneeded.com/project.php?id=11&task=42"
  }
]

Get Task

GET /api.php?resource=tasks&action=get&id=42 — scope: full, ai_agent

Purpose: Read one task, including its full comment history (comments, oldest first) and attached images (images). Use this once you know the ID. Includes the current version for safe updates.

Create Task

POST /api.php?resource=tasks&action=create — scope: full, ai_agent, quick_capture

Purpose: Create a new task inside a project.
Common mistakes / agent guidance: Always pass project_id. If it is omitted, the server substitutes one fixed internal project that exists in only one company's account; for every other company an omitted project_id fails with 404 "Project not found". Never assume a default project exists.
Tenant isolation: The target project must belong to the caller's company, or the request fails with 404.
Not idempotent: A retried call creates a second task.

Field Type Notes
project_id (required in practice) integer Parent project ID. Always pass this explicitly.
title (required) string Task title
description string Optional detail
tags string or array Comma-separated or array; normalized and de-duplicated server-side
status string todo · in-progress · blocked · done (default: todo)
priority string low · normal · high · urgent (default: normal)
category string Optional free-text category
due_date string ISO date: YYYY-MM-DD
delivery_mode string How an AI coding worker should deliver the finished work: commit_push · commit_only · no_commit (default: commit_push). Any other value → 400.

Example request

{ "project_id": 11, "title": "Review contract", "priority": "high", "status": "todo", "due_date": "2026-06-20" }

Example response (201)

{ "id": 42, "message": "Task created", "delivery_mode": "commit_push", "url": "/project.php?id=11&task=42", "canonical_url": "https://hub.justwhatsneeded.com/project.php?id=11&task=42" }

Update Task

POST /api.php?resource=tasks&action=update — scope: full, ai_agent

Purpose: Update one or more fields on an existing task. Only include fields you want to change. Sending no changeable field → 400 "Nothing to update".

Field Type Notes
id (required) integer Task ID to update
title string
description string
tags string or array
status string todo · in-progress · blocked · done
priority string low · normal · high · urgent
category string
due_date string YYYY-MM-DD
delivery_mode string commit_push · commit_only · no_commit. Any other value → 400.
project_id integer Move the task to another project. Must belong to your company, otherwise 404.
version integer Optional. If supplied and stale, the request fails with 409 and returns the current row as current_task — use this to avoid silently overwriting a concurrent edit.

Example request

{ "id": 42, "status": "in-progress", "priority": "urgent", "version": 1 }

Example response — the full, updated task row:

{ "id": 42, "project_id": 11, "title": "Review contract", "status": "in-progress", "priority": "urgent", "delivery_mode": "commit_push", "version": 2, "url": "/project.php?id=11&task=42" }

Complete Task

POST /api.php?resource=tasks&action=complete — scope: full, ai_agent

Purpose: Convenience wrapper that sets status=done. Completing an already-complete task just succeeds again.
Required parameters: id (integer)
Optional parameters: version (integer) — same optimistic-concurrency check as update: a stale value fails with 409 and returns current_task.

Example request

{ "id": 42, "version": 2 }

Example response — the full, updated task row:

{ "id": 42, "status": "done", "version": 3, "url": "/project.php?id=11&task=42" }

Reopen Task

POST /api.php?resource=tasks&action=reopen — scope: full, ai_agent

Purpose: Sets status=todo (Hub doesn't record the earlier status, so nothing else is restored). Same id and optional version as Complete Task, same 409 behavior; returns the full task row.

Task History, Images, Delete

Scope: full only — not available to ai_agent keys.

Action Request Effect / response
history GET …&action=history&id=42 The task's field-change log, newest first.
upload_image POST, multipart form (not JSON): task_id, file jpg/png/gif/webp only, max 5 MB. 201 with {id, file_name, file_path, created_at}.
delete_image POST { "image_id": 9 } Deletes the image file and row. {"message": "Image deleted"}
delete POST { "id": 42 } Permanent. Deletes the task and its comments. {"message": "Task deleted"}

Ideas

A lighter-weight backlog for things worth capturing before they're ready to become a task. Requires the ideas module (otherwise 403). There is no delete action.

Action Scope Request Notes
list full, ai_agent GET …&action=list All of the company's ideas, newest first. Not paginated.
get full, ai_agent GET …&action=get&id=210 One idea. 404 if unknown or another company's.
create full, ai_agent POST See below.
update full, ai_agent POST id (required), any of title, description, status Returns the updated idea. Blank title or an invalid status → 400.
promote full, ai_agent POST See below.

Create Idea

POST /api.php?resource=ideas&action=create — scope: full, ai_agent

Purpose: Capture an idea for later triage — not yet committed engineering work.
Required parameters: title (string)
Optional parameters: description, status (idea · reviewing · ready; default idea), created_by

Example response

{ "id": 210, "title": "...", "status": "idea", "url": "/ideas.php" }

Promote Idea

POST /api.php?resource=ideas&action=promote — scope: full, ai_agent

Purpose: Turns an idea into a real task under a given project. The idea itself is kept (not deleted), status set to ready, promoted_task_id linked to the new task.
Required parameters: id (the idea), project_id (where the new task should live — must belong to your company, otherwise 404)
Common mistakes / agent guidance: An idea can only be promoted once — a second attempt fails with 400.

Example response (201)

{ "idea": { "...": "updated idea, status: ready" }, "task": { "...": "newly created task" } }

Comments

Task comments. Require the tasks module (otherwise 403).

List Comments

GET /api.php?resource=comments&action=list&task_id=42 — scope: full, ai_agent

Purpose: The task's complete comment history, oldest first (ties broken by comment ID). Not paginated.
Required parameters: task_id (integer). 404 if the task is unknown or another company's.

Example response

[
  { "id": 7, "task_id": 42, "author": "Bill", "body": "Ready for review", "created_at": "2026-06-11 10:30:00" }
]

Create Comment

POST /api.php?resource=comments&action=create — scope: full, ai_agent

Purpose: Adds a comment to a task. Not idempotent — a retried call adds a second comment.

Field Type Notes
task_id (required) integer Task to comment on
author (required) string Author name, e.g. Bill or Claude
body (required) string Comment text

Example request

{ "task_id": 42, "author": "Bill", "body": "Ready for review" }

Example response (201) — the new comment:

{ "id": 8, "task_id": 42, "author": "Bill", "body": "Ready for review", "created_at": "2026-06-11 10:31:00" }

Delete Comment

POST /api.php?resource=comments&action=delete — scope: full only

Purpose: Permanently deletes one comment. Body: { "id": 8 }. Response: {"message": "Comment deleted"}. 404 if unknown or another company's.

The general-purpose, canonical way to find a record when you don't already know its ID. This is the preferred discovery mechanism for AI agents — see AI Agent Guidance.

GET /api.php?resource=search&action=search&q=password — scope: full, ai_agent

Purpose: Search across Hub resources by keyword. Today this covers tasks only — the response shape and the type parameter are designed so more resource types (documents, ideas, and others) can be added later without changing this contract, but only task is implemented right now.

When to use: The user describes a record (by title, keyword, or ID) but you don't already know exactly where it lives.
When not to use: Once you have a concrete ID, use the resource's own get action instead of searching again. Don't use search as a substitute for a targeted list when you already know the exact project/filter and have no free-text to match.

Route: GET /api.php?resource=search&action=search — deliberately not the bare ?action=search form; Hub's routing convention (see Routing) requires an explicit resource on every request, even one with a single action.

Authentication & scope: API key or browser session, same as every other endpoint. Included in the ai_agent scoped-key allowlist — this is the intended search mechanism for AI-agent integrations. Not available to quick_capture-scoped keys.

Required parameters:

Param Notes
q Search text. Missing or blank (after trimming) → 400. See Query forms below.

Optional parameters:

Param Default Notes
type all supported types Comma-separated resource types. Only task is implemented today, so task is currently the only accepted value; requesting any other type (e.g. document) returns 400 rather than silently returning nothing.
project_id none Restrict task results to one project.
status none todo · in-progress · done · blocked
priority none low · normal · high · urgent
include_completed false When false (default), status=done tasks are excluded.
limit 25 Maximum 100. Values above 100 are clamped to 100, not rejected. Zero, negative, or non-numeric values → 400.
offset 0 Negative or non-numeric values → 400.

Query forms: q is interpreted as a whole (after trimming):

Form Example Behavior
Plain text password reset Case-insensitive substring match against task title and description.
Quoted exact phrase "password reset" The surrounding quotes are stripped and the phrase is matched literally as one substring.
ISO date 2026-07-23 A valid YYYY-MM-DD date is matched literally as text (e.g. a date written in a title or description) — it is not a date-range filter.
Task ID 6624 · #6624 · task:6624 · [[task:6624]] Also matched exactly against the task's numeric id (in addition to the text match). Only a query that is entirely one of these forms counts — 42a, tasks:6624, or [[task:6624] are plain text.

A literal % or _ in q is always treated as a literal character, never a SQL wildcard.

Ranking / order: Results are ordered by, in priority order: (1) exact task-ID match, (2) exact title match, (3) title contains the query, (4) description contains the query, then (5) most-recently-updated first as the tiebreaker within any of the above.

Validation rules and limits: A filter that matches nothing — an unrecognized status/priority value, or a project_id with no matching tasks — returns an empty results array, not an error. A project_id belonging to a different company also returns an empty array, identically to any other non-matching filter — this is deliberate and matches Hub's tenant-isolation convention of never revealing, even indirectly, whether a record exists outside the caller's own company.

Module entitlement: Each result type checks the entitlement for its own module independently. Today that means: if the tasks module is disabled for the company, the task type simply contributes zero results — the whole search request still succeeds. This is intentional: a future search spanning several resource types must not have every type blocked just because one of that company's modules happens to be off.

Tenant isolation: Only tasks belonging to the caller's own company are ever returned, enforced the same way as every other tasks endpoint.

Response shape: A JSON object with query, results (an array), and pagination. Each result includes:

Field Notes
type Currently always "task".
id, project_id, project_name, title, status, priority Same values as the tasks endpoints.
description_excerpt A bounded preview of the description, centered on the match when there is one. null if the task has no description.
matched_fields Which of id, title, description actually matched — e.g. ["title","description"].
url The same canonical /project.php?id=...&task=... link the tasks endpoints return.

pagination is { "limit": ..., "offset": ..., "has_more": ... } — see Pagination.

Status / error responses: 200 success (including a legitimate zero-result search) · 400 missing/blank q, unsupported type, or invalid limit/offset · 401 missing/invalid authentication.

Common mistakes / agent guidance: Don't request type=document, type=idea, or type=all — only task exists today, and anything else is a 400, not an empty result. Don't treat a zero-result search as proof a record doesn't exist anywhere — it only means nothing matched in this tenant's currently-searchable data.

Search Examples

1. Normal keyword search

GET /api.php?resource=search&action=search&q=password
X-API-Key: your-api-key-here
{
  "query": "password",
  "results": [
    {
      "type": "task",
      "id": 1104,
      "project_id": 11,
      "project_name": "Platform Polish",
      "title": "Add Admin Password Reset",
      "description_excerpt": "Platform administrators need a safe way to reset...",
      "status": "todo",
      "priority": "high",
      "matched_fields": ["title", "description"],
      "url": "/project.php?id=11&task=1104"
    }
  ],
  "pagination": { "limit": 25, "offset": 0, "has_more": false }
}

2. Search restricted to a project

GET /api.php?resource=search&action=search&q=reset&project_id=11
X-API-Key: your-api-key-here

3. Task-ID search (bare, #, task: or [[task:…]] form)

GET /api.php?resource=search&action=search&q=%231104
X-API-Key: your-api-key-here
{ "query": "#1104", "results": [ { "type": "task", "id": 1104, "matched_fields": ["id"] } ], "pagination": { "limit": 25, "offset": 0, "has_more": false } }

4. Exact-phrase search

GET /api.php?resource=search&action=search&q=%22password%20reset%22
X-API-Key: your-api-key-here

5. Pagination

GET /api.php?resource=search&action=search&q=onboarding&limit=10&offset=10
X-API-Key: your-api-key-here

6. Zero-result response

GET /api.php?resource=search&action=search&q=zzz_no_such_term_zzz
X-API-Key: your-api-key-here
{ "query": "zzz_no_such_term_zzz", "results": [], "pagination": { "limit": 25, "offset": 0, "has_more": false } }

7. Invalid resource type → 400

GET /api.php?resource=search&action=search&q=x&type=document
X-API-Key: your-api-key-here
{ "error": "Unsupported type(s): document. Supported types: task" }

8. Unauthenticated request → 401

GET /api.php?resource=search&action=search&q=password
{ "error": "Unauthenticated" }

Documents and Folders

Markdown documents, organized in folders and optionally attached to a project. Requires the documents module (otherwise 403). Every action only touches the caller's own company.

Action Scope Request Notes
search full, ai_agent GET …&action=search&q=onboarding Matches title and summary. Add include_content=true to search the body too (full-text; words shorter than 3 characters are ignored there). "…" matches an exact phrase and a bare YYYY-MM-DD matches that date literally. sort: title_asc (default), title_desc, updated_desc, updated_asc, created_desc, created_asc. Returns { "documents": [...], "attachments": [...] }: up to 50 documents and up to 20 attachment filename matches.
get full, ai_agent GET …&action=get&id=12 One document, including markdown_content and its current version. 404 if unknown or another company's.
list_by_type full, ai_agent GET …&action=list_by_type&document_type=journal&journal_date=2026-10-27 document_type is required (document or journal). The usual use is finding the Daily Journal for a date.
create full, ai_agent POST See below.
update full, ai_agent POST id and version (both required), then any of title, markdown_content, summary, document_type, journal_date version is required here (unlike tasks): omit it and you get 400; a stale one returns 409 with the current row as current_document. Every save writes a revision. An empty summary clears it.
move full, ai_agent POST id, plus folder_id and/or project_id Re-files the document. No revision is written and version does not change.
list full only GET …&action=list (optional folder_id, project_id, sort) ai_agent keys use search instead.
delete, bulk_move full only POST delete is a soft delete. bulk_move takes ids and folder_id.

Create Document

POST /api.php?resource=documents&action=create: scope full, ai_agent

Purpose: Create a Markdown document. Not idempotent: there is no dedupe key, so a retried call creates a second document. If a retry is possible, search before creating again.

Field Type Notes
title (required) string
markdown_content string The body, in Markdown
summary string Short description, shown in lists and matched by search
folder_id integer Must be one of your company's folders (404 otherwise)
project_id integer Must be one of your company's projects (404 otherwise)
document_type string document (default) or journal
journal_date string YYYY-MM-DD. Kept only when document_type is journal, and cleared otherwise. Hub does not enforce one journal per day, so check with list_by_type first.

Example response (201): the new document, with its first revision already recorded:

{ "id": 12, "title": "Onboarding checklist", "document_type": "document", "folder_id": 4, "project_id": null, "version": 1, "markdown_content": "...", "url": "..." }

Folders

Folders live under the same documents module.

Action Scope Request Notes
create full, ai_agent POST name (required), parent_id Returns the new folder (201).
move full, ai_agent POST id (required), parent_id (null moves it to the top level) The folder's whole subtree moves with it, and no document is modified. Moving a folder into itself or one of its own descendants returns 400.
list, update, delete full only delete only works on an empty folder (409 otherwise).

Document Attachments

Files attached to a document. Same documents module.

Action Scope Request Notes
list full, ai_agent GET …&action=list&document_id=12 Metadata only: id, original_name, display_name, mime_type, file_size, uploaded_by, created_at.
download full, ai_agent GET …&action=download&id=5 Streams the file's raw bytes with Content-Type, Content-Disposition and Content-Length headers. This is the one action whose success response is not JSON.
upload, update, delete full only upload is multipart/form-data with document_id and file update renames the display name; delete is a soft delete.

Activity Events

A durable log of meaningful work: what was started, decided, finished or found, linked to a project, task or document. This is the main way an AI agent records what it did. Requires the tasks module (otherwise 403).

Action Scope Request Notes
create full, ai_agent POST See below.
list full, ai_agent GET …&action=list&task_id=42 Newest first. Filters: project_id, task_id, document_id, event_type, performed_by, since, until. limit (1–500, default 100) and offset. See Pagination.
get full, ai_agent GET …&action=get&id=901 One event. 404 if unknown or another company's.
list_for_day full only GET …&action=list_for_day&date=2026-10-27 Every event for one America/Chicago calendar day, oldest first. Feeds the Daily Journal.

Record an Activity Event

POST /api.php?resource=activity_events&action=create: scope full, ai_agent

Purpose: Record one meaningful piece of work. Idempotent when you send client_dedupe_key: a retry with the same key returns the original event with "duplicate": true instead of creating a second one. Without a key, a retry creates a duplicate.

Field Type Notes
event_type (required) string work_started · progress · decision · task_created · task_completed · bug_found · commit_pushed · meeting · note
summary (required) string What happened, in a sentence or two
project_id, task_id, document_id integer What it relates to. Each must belong to your company.
decisions, files_affected, next_step, session_title string Optional detail
performed_by_type string user (default) · agent · system · integration
performed_by string Free-text label for the actor, e.g. Claude Code
source_reference string A commit SHA, conversation ID or similar
context_json object Any extra structured metadata
client_occurred_at string When you say it happened, as YYYY-MM-DD HH:MM:SS. Stored for comparison only: the event's authoritative occurred_at is always Hub's server time.
client_dedupe_key string Idempotency key, unique per company

Example request

{ "event_type": "task_completed", "summary": "Fixed the login redirect loop", "task_id": 42, "performed_by_type": "agent", "performed_by": "Claude Code", "client_dedupe_key": "task-42-done" }

Example response (201): the event, plus duplicate:

{ "id": 901, "event_type": "task_completed", "summary": "Fixed the login redirect loop", "task_id": 42, "occurred_at": "2026-10-27 21:05:12", "duplicate": false }

Connections and Who

The relationships resource tracks Connections: ongoing relationships with a person or organization (a sale, a vendor, a job search, a customer), each with a timeline of communications, a "next move" that says who owes what by when, and participants linked to people in Who, Hub's contacts directory. It is not module-gated.

For an ai_agent key, reads are open and writes are narrow. There are no deletes. Confirming or rejecting a person's identity, and adding or closing a next move on its own, are human-only. Writes made with an ai_agent key are always recorded as agent-originated, whatever the request body says.

Action Request Notes
engagements.list GET Every Connection with its most urgent open next move, last communication and an attention level (overdue · no_next_action · due_soon · stale · ok), most urgent first. Filters: state, category, contact_ref, attention, stale=true, q (title or any person or organization name), owner (us · them · external). Not paginated.
engagement.get GET id One Connection with its full timeline, next moves, participants, links and audit trail.
engagement.find_duplicate GET title An open Connection with exactly this title (case-insensitive), if there is one. Call it before engagement.create.
engagement.create POST See below.
engagement.update POST id, optional version Change state, headline, category, title, next_review_date or the Who link. A stale version returns 409 with current_engagement. To record a communication too, use event.log instead.
engagement.events GET id The Connection's audit trail, newest first.
event.log POST See below.
participant.add POST engagement_id, plus contact_ref or person_name Add a person, with a role. identity_status: "review" asks a human to confirm who they are.
participant.update POST id, engagement_id Change role, primary flag, note or organization.
link.add POST engagement_id, linked_type Attach a task, document, project, job, expense, communication or url.
who.search GET q (2+ characters), limit (default 20) Search people in Who. Returns available: false and no results when the directory can't be reached, rather than failing.
who.resolve GET refs (comma-separated) Check that Who IDs still resolve, with each person's current name, organization and email.
who.get GET contact_ref One person in full, including the updated_at value that who.update needs. 503 if the directory is down.
who.update POST contact_ref, optional expected_updated_at Change only the fields you send (name, company, job title, primary email, phone, mobile). null or "" clears an optional field.
identity.propose POST participant_id, candidates Suggest Who records for a person whose identity is uncertain. A human decides; nothing is merged automatically.
identity.review_queue GET People waiting on a human identity decision.
comm_review.submit POST source_type, summary, proposal_kind Hand over a communication you couldn't confidently file, with your proposal (existing_engagement, new_engagement or unknown). It is only stored; a human files it.
comm_review.queue, comm_review.get GET Pending hand-overs, or one of them by id.
report GET "Where do we stand?": one row per Connection with who owes the next move and by when, plus summary counts. Same filters as engagements.list, plus include_complete.

Values used across these actions:

Field Values
state action_needed · waiting_on_them · scheduled · active · dormant · complete
category revenue · customer_success · onboarding · vendor · partner · job_hunt · personal · operational
next-move owner us · them · external
participant role primary_contact · approver · technical · team_member · referrer · assistant · participant · other

Create a Connection

POST /api.php?resource=relationships&action=engagement.create: scope full, ai_agent

Purpose: Start tracking a relationship. In the same call you can add the first person, the first next move and links. No communication is recorded. Use event.log for that.
Required parameters: title, category
Optional parameters: state (default action_needed), headline, project_id, person (one first person) or participants (several), next_obligation ({ "owner": "us", "next_action": "Send proposal", "due_date": "2026-11-03" }; due_date is optional), links, next_review_date
Common mistakes / agent guidance: This action does not check for an existing Connection with the same title unless you send guarded_create: true, so call engagement.find_duplicate first. With guarded_create: true, an open Connection with the same title returns 409 (send confirm_duplicate: true to create it anyway). A state of action_needed, waiting_on_them or scheduled with no next_obligation returns 422 (send allow_no_next_action: true to save it anyway).

Log a Communication

POST /api.php?resource=relationships&action=event.log: scope full, ai_agent

Purpose: Record one communication on a Connection and, in the same call, optionally move its state, resolve an open next move and set the next one. History is append-only: a logged communication can't be edited or deleted.

Field Type Notes
engagement_id (required) integer
direction (required) string inbound · outbound · internal
channel (required) string email · phone · zoom · meeting · message · text · note · decision
summary (required) string What was said or decided
occurred_at string When it happened (may be in the past). Defaults to now.
state string Move the Connection to this state
resolve_obligation_id integer Close this open next move (it must belong to this Connection)
obligation_resolution string With resolve_obligation_id: satisfied (default) · superseded · cancelled · rescheduled
next_obligation object The next move: owner, next_action, optional due_date
expected_version integer Optional guard. A mismatch returns 409.
client_dedupe_key string Idempotency key. A retry returns the original event with duplicate: true and changes nothing else.

Identity and Timezone

Two small read-only resources an agent can call at the start of a session to learn who it is acting as and what time it is for the user. Neither is module-gated.

Request Scope Returns
GET …?resource=identity&action=whoami full, ai_agent company_id, company_name, user_id (null when you call with an API key alone), scope, auth_method (api_key or session), timezone, timezone_source, enabled_apps
GET …?resource=timezone&action=get full, ai_agent timezone (an IANA name such as America/Chicago), timezone_source (user_preference, company_default or utc_fallback), plus the raw user_preference and company_default
GET …?resource=timezone&action=now full, ai_agent The current time as formatted, date, time, timezone, timestamp_utc and timezone_source
GET …?resource=timezone&action=format&timestamp=2026-10-27%2021:05:12 full, ai_agent The given UTC timestamp (YYYY-MM-DD HH:MM:SS, URL-encoded) formatted the same way. timestamp is required.

now and format use the caller's effective timezone unless you pass timezone (an IANA name; offsets and abbreviations such as CST are rejected with 400). Pass hour12=false for a 24-hour clock. Hub stores and returns timestamps in UTC; use format rather than converting them yourself.

Setting a timezone isn't available to ai_agent keys. timezone&action=update_user sets your own preference and needs a signed-in user. update_company_default sets the company default and needs a company admin or a full-scope key.

Pagination

Hub uses limit/offset pagination. Only search returns a pagination object. The list actions for projects, tasks, ideas, comments, document attachments and Connections return every matching row in one response, and document search returns at most 50 documents.

Search responses include a pagination object:

Field Meaning
limit The effective page size actually used (after any clamping).
offset How many matching rows were skipped before this page.
has_more true if requesting the next page (same limit, offset advanced by limit) would return more results.

To page through results: keep the same filters and limit, and increase offset by limit each time until has_more is false.

A few endpoints accept a size limit but return no pagination object or has_more. For those, getting fewer results than limit means you have reached the end. See the OpenAPI specification for details:

Endpoint Parameters
activity_events&action=list limit (1–500, default 100), offset
briefings&action=list limit (default 30)
document_share_views&action=list limit (1–200, default 50)
relationships&action=who.search limit (default 20)

Errors

Code Meaning
200 Success — request completed, data returned. (A legitimate zero-result search, and a projects&action=create that found an existing project, are both 200.)
201 Created — resource was successfully created
400 Bad request — missing required field, invalid value, nothing to update, an unsupported search type, an unknown action, or resource/action sent in the body instead of the query string
401 Unauthorized — missing or invalid API key/session, a suspended company's key or session, or a scoped key attempting an action outside its scope (all identical to an invalid key)
403 Forbidden — the resource's Hub module is not enabled for the company ({"error": "This application is not enabled for your account", "module": "tasks"}), or the caller lacks a specific permission (e.g. editing someone else's comment)
404 Not found — record doesn't exist or belongs to another company (the two are never distinguished, by design), or an unknown resource (a scoped key gets 401 instead, because the scope check runs first)
409 Conflict — a supplied version is stale on tasks&action=update, complete, or reopen (the response includes the current row as current_task); also documents&action=update (current_document), Connections engagement.update (current_engagement) and event.log with expected_version, and other resources in the OpenAPI specification. Also returned for a title collision on a guarded engagement.create.
410 Gone — the retired interactions resource. Use activity_events instead.
422 Only from engagement.create with guarded_create: true, when a Connection that needs a next move has none
503 Service unavailable — a dependent service is temporarily down (e.g. the Who directory behind some Connections actions). Retry later.

The resources in this guide report validation failures as 400. The one exception is engagement.create with guarded_create: true, which returns 422 when a Connection that needs a next move has none.

Error responses always return a JSON object with an error field (some add context, such as module or current_task):

{ "error": "Missing required field: title" }

Machine-Readable Reference

The full, detailed machine contract — every field, every schema, every response shape — lives in one place: the OpenAPI specification. This guide is the operational usage guide; the OpenAPI document is the authoritative detailed schema.

OpenAPI spec https://hub.justwhatsneeded.com/docs/openapi.yaml
Format OpenAPI 3.0.3, YAML
Markdown / AI knowledge version of this guide https://justwhatsneeded.com/api-guide.md
Structured data (HTML version only) <script type="application/ld+json"> in the page <head> (Schema.org TechArticle / WebAPI)

The OpenAPI specification documents every operation api.php routes, and a test in the Hub repository fails if one goes missing. Beyond the resources in this guide, it covers Briefings, Songs, document revisions, shares and sets, task images and dependencies, company settings, email drafts (Outbox), work sessions, schedules and file transfer. Operations tagged Internal are Hub's own AI-orchestration, operations, billing and test-harness surface. They are specified for completeness, are not a supported integration point, and may change without notice. Each operation has its own "Scopes" line saying which keys can call it. If you need a resource's exact field types, enums or nullability and this guide doesn't spell them out, the OpenAPI document is authoritative.

Keeping This Guide Current

This guide has one canonical source — docs/api-guide-source.md in the jwn-hub repository — generated into api-guide.html and api-guide.md by scripts/generate-api-guide.php, and cross-checked against docs/openapi.yaml by tests/validate-api-guide.php. Nobody edits html/api-guide.html or html/api-guide.md directly; both are build output and get overwritten the next time the generator runs.

Whenever a public Hub API capability, route, parameter, response contract, authentication rule, scope, module entitlement, or error behavior changes: edit docs/api-guide-source.md, run php scripts/generate-api-guide.php, and update docs/openapi.yaml — all before that work is considered complete. This is a standing Hub engineering requirement (Hub Engineering Standard, Section 8), not a one-off effort tied to any single change.


© 2026 Just What's Needed · Back to Hub overview