gantt-to API v1¶
Gantt charts as data. Read or replace an entire chart in one request, and wire parents and dependencies with keys you choose rather than server-generated ids.
| Base URL | https://api.gantt-to.work |
| Format | JSON, UTF-8. Send Content-Type: application/json |
| Dates | YYYY-MM-DD |
| Timestamps | RFC3339, UTC |
| Ids | UUID strings |
| Max request body | 4 MB |
Machine-readable: /v1/openapi.json.
This document, as markdown: /llms.txt.
Authentication¶
Send an API key on every request:
Authorization: Bearer gt_live_xxxxxxxxxxxxxxxxxxxxxxxx
Create a key at app.gantt-to.work under Settings → API keys. It is shown once, at creation, and cannot be recovered afterwards.
A key carries the full authority of the account that created it, with one exception: it cannot create or revoke keys. That requires a signed-in browser, so a leaked key cannot extend its own life. There is no header or scope that lifts it.
Requests without credentials to a non-public route return 401.
Errors¶
Every non-2xx response has this shape and no other:
{"error": {"code": "invalid_request", "message": "human readable", "field": "start_date"}}
field is present only when a single field is at fault.
| code | status | meaning |
|---|---|---|
invalid_request |
400 | malformed or rejected input |
unauthorized |
401 | no valid credentials |
plan_limit |
402 | an account limit would be exceeded |
forbidden |
403 | authenticated, but the role is too low |
not_found |
404 | no such resource, or none you may see |
conflict |
409 | the request contradicts current state |
rate_limited |
429 | too many requests; honour Retry-After |
internal |
500 | server fault |
Four behaviours worth knowing before you write a client:
404 also means "exists, but not for you." A private project you are not a
member of returns 404, never 403, so existence never leaks. Do not retry a
404.
Unknown fields are rejected. {"colour": "red"} or
{"startDate": "2026-09-01"} returns 400 rather than being ignored. Field
names are snake_case, exactly as spelled here.
An unresolvable ref is 400, not 404. It is a mistake in your payload,
not a missing resource.
Bulk writes are atomic. If any element is rejected, nothing is written. The message names the offending element by index:
{"error":{"code":"invalid_request","message":"tasks[3]: end_date is required","field":"end_date"}}
A failure on the first element omits the index and reads name is required,
so parse the prefix when present rather than requiring it.
All 4xx responses are deterministic. The same request fails the same way.
Objects¶
Org {id, slug, name, owner_id, role, created_at, updated_at}
role is the caller's own role: owner | admin | member | viewer
Member {user_id, email, name, role}
Project {id, org_id, org_slug, slug, name, description, visibility,
created_at, updated_at}
visibility: private | public
Task {id, ref, project_id, parent_id, name, notes, start_date, end_date,
progress, color, assignee, is_milestone, sort_order}
progress 0-100. end_date >= start_date. sort_order ascending.
Dependency {id, project_id, predecessor_id, successor_id, type, lag_days}
type: FS | SS | FF | SF. lag_days may be negative.
Gantt {project: Project, tasks: [Task], dependencies: [Dependency]}
Write shapes:
TaskInput {id?, ref?, parent_ref?, parent_id?, name, notes?, start_date,
end_date, progress?, color?, assignee?, is_milestone?, sort_order?}
DepInput {predecessor_ref? | predecessor_id?, successor_ref? | successor_id?,
type?, lag_days?}
Give each dependency end exactly one of its _id or _ref form; both ends are
required.
On PUT /gantt and POST /tasks:batch, a task with is_milestone: true may
omit end_date; it is copied from start_date. POST /tasks requires it
either way.
Required TaskInput fields depend on the route:
| route | required |
|---|---|
PUT /gantt |
name, start_date, end_date on every task, and every other field you want kept |
POST /tasks:batch |
name, start_date, end_date only on tasks that do not exist yet |
POST /tasks |
name, start_date, end_date |
PATCH /tasks/{id} |
nothing; send only what changes |
Endpoints¶
Slugs are lowercase and hyphenated. Org slugs are globally unique; project slugs
are unique within their org. A taken slug is 409.
Account¶
GET /healthz |
200 {status, db}. No auth. |
GET /v1/me |
200 {user, orgs: [Org], limits} |
Organizations¶
POST /v1/orgs |
{name, slug} → 201 {org}. You become owner. |
GET /v1/orgs |
200 {orgs: [Org]} |
GET /v1/orgs/{orgID} |
200 {org, members: [Member]} |
PATCH /v1/orgs/{orgID} |
{name} → 200 {org}. admin+ |
DELETE /v1/orgs/{orgID} |
204. owner only. Cascades. |
Members¶
POST /v1/orgs/{orgID}/members |
{email, role} → 201 {member}. admin+ |
PATCH /v1/orgs/{orgID}/members/{userID} |
{role} → 200 {member}. admin+ |
DELETE /v1/orgs/{orgID}/members/{userID} |
204. admin+, or a member removing themselves. |
Invites take an email address. If no account has it, the membership attaches to a placeholder that the person claims when they first sign in with that address. No mail is sent; tell them yourself.
| condition | status |
|---|---|
role is not exactly admin, member or viewer — case-sensitive, untrimmed |
400 |
role is owner |
400 |
| only the owner may add, re-role or remove an admin | 403 |
already a member; use PATCH |
409 |
| the org owner's own membership | 409, immutable |
Projects¶
POST /v1/orgs/{orgID}/projects |
{name, slug, description?, visibility?} → 201 {project} |
GET /v1/orgs/{orgID}/projects |
200 {projects: [Project]} |
GET /v1/projects/{projectID} |
200 {project} |
PATCH /v1/projects/{projectID} |
any of {name, slug, description, visibility} → 200 {project} |
DELETE /v1/projects/{projectID} |
204. Cascades to tasks and dependencies. |
Charts¶
GET /v1/projects/{projectID}/gantt |
200 Gantt |
PUT /v1/projects/{projectID}/gantt |
{tasks, dependencies?} → 200 Gantt. Replaces the chart. |
POST /v1/projects/{projectID}/tasks:batch |
{tasks?, dependencies?} → 200 Gantt. Upserts. Needs at least one element. |
GET /v1/projects/{projectID}/critical-path |
200 CriticalPath. A read; it writes nothing back into the chart. |
Tasks and dependencies¶
POST /v1/projects/{projectID}/tasks |
TaskInput → 201 {task} |
PATCH /v1/tasks/{taskID} |
partial TaskInput → 200 {task} |
DELETE /v1/tasks/{taskID} |
204. Cascades to children and edges. |
POST /v1/projects/{projectID}/dependencies |
DepInput → 201 {dependency} |
DELETE /v1/dependencies/{depID} |
204 |
An edge is unique per (predecessor, successor); sending it again updates its
type and lag_days. A cycle or a self-edge is 409, message
dependency would create a cycle.
Public¶
GET /v1/public/{orgSlug}/{projectSlug}/gantt |
200 Gantt when the project is public, else 404. No auth. |
GET /v1/public/{orgSlug}/{projectSlug}/critical-path |
200 CriticalPath when the project is public, else 404. No auth. |
Writing a chart¶
PUT /gantt means the chart is exactly this.
POST /tasks:batch means apply these changes.
They differ at the field level, not only the row level:
PUT /gantt |
POST /tasks:batch |
|
|---|---|---|
| task in the project, absent from the body | deleted | untouched |
| field omitted on a task in the body | reset to default | untouched |
parent_ref / parent_id omitted |
moved to top level | untouched |
dependencies |
replaced wholesale | upserted; none removed |
omitting the dependencies key |
deletes every edge | changes nothing |
Under PUT, an omitted field on a task you did send is reset: notes and
color and assignee to "", progress and sort_order to 0,
is_milestone to false, ref to null, parent to top level. name,
start_date and end_date are never reset because PUT requires them.
So a PUT task object must be complete. The safe pattern is GET /gantt,
edit the objects you received, and send all of them back. PUT accepts and
ignores the project key so that round trip works.
Deleting a task cascades to its children. Under PUT that only reaches children
the body leaves out: a child the body does list is matched by ref or id,
keeps its identity, and moves to top level when its parent goes.
Use tasks:batch for anything incremental. Use PUT only when you can state
what every task in the project should look like.
Ref keys¶
ref is a caller-supplied string, unique within a project, nullable. It lets
one request create a chart and wire its parents and dependencies without knowing
any server-generated id.
| field | resolves to |
|---|---|
ref |
declares this task's key |
parent_ref |
the parent task |
predecessor_ref, successor_ref |
the ends of an edge |
Refs resolve against the request body first, then against refs already stored in
the project. On a task, id wins over ref; with neither, the task is created.
Under PUT, omitting ref on a task matched by id sets it to null and loses
the handle. Always send ref back.
Critical path¶
CriticalPath {project_id, start_date, end_date, duration_days,
critical_task_ids: [id], tasks: [TaskFloat]}
TaskFloat {id, ref, late_start, late_end, total_float_days, is_critical}
total_float_days is how many days a task can slip before it pushes a task that
depends on it, or pushes the end of the plan. Zero means it cannot slip at all,
which is what puts it on the critical path.
This is a read, not a scheduler. Nothing here moves a bar or writes anything. Dates are whatever you set, so slack you left on purpose is reported as slack rather than squeezed out - which is the opposite of what an auto-scheduling tool does, and worth knowing before comparing the numbers with one.
All four edge types constrain the answer, with lag_days, end dates inclusive:
| type | the rule |
|---|---|
FS |
successor starts after predecessor ends, plus lag |
SS |
successor starts no earlier than predecessor starts, plus lag |
FF |
successor ends no earlier than predecessor ends, plus lag |
SF |
successor ends no earlier than predecessor starts, plus lag |
Three details easier to read here than to infer:
- Float is never negative. A chart whose dates already contradict one of its
own dependencies reports
0, not-4. The honest answer there is "no slack"; a negative number would read as a measurement of something. - Nothing may outlast the plan. A task's late finish is capped at the last end date on the chart even where its own successors would allow more.
- A parent is an ordinary task. This API does not roll a parent's dates up from its children, so a parent on the critical path is a fact about the dates it was given.
An empty project answers 200 with start_date and end_date null,
duration_days 0 and both arrays empty.
Permissions¶
| action | required role |
|---|---|
| read a private project, its tasks and dependencies | viewer |
| create, update or delete a project, task or dependency | member |
| update the org; invite, re-role or remove members | admin |
| add, re-role or remove an admin; delete the org | owner |
| read a public project | none |
A role too low returns 403 where you can already see the resource, 404 where
you cannot.
Limits¶
Accounts have limits on organizations, projects per organization, tasks per
project, API keys and editors. Exceeding one returns 402 with a message
naming the limit.
Viewers are free and unlimited on every plan. The editor seat count covers only the roles that can change something - owner, admin, member - so adding somebody who can read costs nothing. Charging to let a person look at a chart would leave "make it public" as the cheap way to show one, and an internal plan is not something to publish by accident.
Tasks per project is the same number on every plan, free included. What a plan costs is how many plans you keep and how many people can write to them, not how big one of them is - a chart that does not fit proves nothing. Like the rate limit, the task ceiling is a safety net rather than a tier feature, so nobody is throttled into upgrading.
Read the values that apply to you from GET /v1/me; -1 means unlimited. Do
not hard-code them.
Limits on org-scoped resources are measured against the organization's owner, not the caller.
A 402 never means data was lost. It refuses one new thing. Anything that
already exists stays readable and editable, including whatever sits above a
limit after it changes. Retrying will not clear it.
While paid plans are closed the message does not name an upgrade. They are
not on sale yet on this deployment, so a 402 says the limit cannot be raised
today rather than telling you to buy a tier you cannot buy. GET /v1/plans
reports available: false in that state, and every tier in it is
purchasable: false. Read available before offering anyone a way to pay.
MCP¶
POST /mcp is a Model Context Protocol server over Streamable HTTP. It exists
so an AI client can be pointed at this API without reading anything first.
It is an adapter, not a second API. Every tool dispatches one of the routes
above, so authorization, plan limits, validation and the error envelope are the
same whichever way you arrive. A tool call that fails answers 200 with
isError true and this API's own error body as its text, which is what lets a
model read the reason and correct its next call.
| tool | the route it is |
|---|---|
whoami |
GET /v1/me |
list_organizations |
GET /v1/orgs |
create_organization |
POST /v1/orgs |
list_projects |
GET /v1/orgs/{orgID}/projects |
create_project |
POST /v1/orgs/{orgID}/projects |
get_chart |
GET /v1/projects/{projectID}/gantt |
write_chart |
PUT /v1/projects/{projectID}/gantt |
update_chart |
POST /v1/projects/{projectID}/tasks:batch |
critical_path |
GET /v1/projects/{projectID}/critical-path |
share_project |
PATCH /v1/projects/{projectID} |
An agent holding nothing but a key can therefore create the organization, create the project, write the whole plan and publish a read-only link, without a person opening the app first.
write_chart and update_chart are the two bulk writes and mean different
things: read the section above before choosing. The initialize response
repeats that warning in its instructions, because it is the one thing a tool
list cannot convey.
Practical notes:
- Authenticate with the same bearer key the REST API takes. The handshake and
tools/listneed no credential; each tool checks one at the route it stands for. - Protocol version
2025-06-18. No session id is issued and no server-initiated stream is offered, soGET /mcpanswers405. - One JSON-RPC object per POST. Batches are refused with
400; MCP dropped them in this version. - A notification - a request with no
id- is answered202with no body. - The rate limit is charged once for the POST, not once per underlying route.
Rate limiting¶
Requests are rate limited, and the limit is the same on every plan. It is a safety net against a caller stuck in a retry loop, not a tier feature; no endpoint, capability or throughput is sold here.
Over the limit is 429 with the rate_limited code and a Retry-After header
in seconds. Unlike a 402, a 429 does clear on its own: wait the stated delay
and retry.
The budget is per credential, so one API key looping cannot spend another key's allowance. Requests with no credential are budgeted per address.
GET /healthz is never limited, so a throttled health check can never be
mistaken for the service being down.
You are very unlikely to meet this. A whole chart is written in one call, so a client that needs hundreds of requests a second is repeating itself - and that is exactly what the limit is here to interrupt.
Quickstart¶
export GT=gt_live_xxxxxxxxxxxxxxxxxxxxxxxx
export API=https://api.gantt-to.work
Create an organization and a project:
curl -s -X POST $API/v1/orgs -H "Authorization: Bearer $GT" \
-H 'Content-Type: application/json' \
-d '{"name":"Acme Inc","slug":"acme"}'
# -> 201 {"org":{"id":"3f2504e0-...","slug":"acme","role":"owner", ...}}
curl -s -X POST $API/v1/orgs/3f2504e0-.../projects -H "Authorization: Bearer $GT" \
-H 'Content-Type: application/json' \
-d '{"name":"Apollo Launch","slug":"apollo"}'
# -> 201 {"project":{"id":"8f14e45f-...", ...}}
Write the whole chart in one request. No task ids appear anywhere; the refs carry the structure:
curl -s -X PUT $API/v1/projects/8f14e45f-.../gantt -H "Authorization: Bearer $GT" \
-H 'Content-Type: application/json' \
-d '{
"tasks": [
{"ref":"phase-1","name":"Phase 1","start_date":"2026-09-01","end_date":"2026-09-25","sort_order":1},
{"ref":"design","parent_ref":"phase-1","name":"Design","start_date":"2026-09-01","end_date":"2026-09-07","progress":100,"sort_order":2},
{"ref":"build","parent_ref":"phase-1","name":"Build","start_date":"2026-09-08","end_date":"2026-09-25","sort_order":3},
{"ref":"launch","name":"Launch","start_date":"2026-09-30","is_milestone":true,"sort_order":4}
],
"dependencies": [
{"predecessor_ref":"design","successor_ref":"build"},
{"predecessor_ref":"build","successor_ref":"launch","lag_days":2}
]
}'
# -> 200 Gantt, ids assigned and both edges resolved
Add a task later without disturbing the rest — note tasks:batch, not PUT:
curl -s -X POST $API/v1/projects/8f14e45f-.../tasks:batch -H "Authorization: Bearer $GT" \
-H 'Content-Type: application/json' \
-d '{
"tasks": [{"ref":"qa","name":"QA","start_date":"2026-09-26","end_date":"2026-09-29","sort_order":5}],
"dependencies": [{"predecessor_ref":"build","successor_ref":"qa"}]
}'
# -> 200 Gantt with five tasks; the other four keep every field
The same body sent as a PUT would have deleted those four.