4.3 — Generation endpoints
On this page
4.3 — Generation endpoints
AI generation tasks (features, user stories, schema, etc.) run as long-lived background jobs. You submit a task, poll or subscribe for progress, and get the results saved into your project when it finishes.
If you want to trigger a regeneration from the web app, use the buttons there — they call these same endpoints under the hood. If you want to automate it (e.g. nightly regenerations, or programmatic onboarding), this is the API.
Triggering generation
Submit a task
POST /api/tasks/submit
Authorization: Bearer vm_your_token_here
Content-Type: application/json
{
"projectId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"prompt": "A dive-log app for recreational scuba divers…",
"taskType": "features",
"modelId": "gemini-3-flash"
}
| Field | Required | Notes |
|---|---|---|
projectId | Yes | The project the results are written into |
prompt | Yes | What to generate from. Max 100,000 characters — sized for the MCP codebase-digest flow. (projectDescription is accepted as a legacy alias) |
taskType | No | Defaults to features. See the table below |
modelId | No | Defaults to gemini-3-flash. Omitting it does not auto-select per task type on this route |
Response 200 OK:
{
"sessionId": "s1a2b3c4-d5e6-7890-abcd-ef1234567890",
"taskId": "s1a2b3c4-d5e6-7890-abcd-ef1234567890",
"status": "queued",
"message": "Task submitted successfully"
}
taskId is the same value as sessionId, kept for older clients. The endpoint returns immediately — generation runs asynchronously. Subscribe to progress (below) or poll for the result.
When submit refuses
Five refusals are worth handling explicitly, because they mean different things:
| Status | Body | What happened |
|---|---|---|
400 | { "error": "Project ID is required" } / "Project description or prompt is required" / "Description too long (max 100000 characters)" | Malformed request |
402 | { "code": "PLAN_UPGRADE_REQUIRED" } or { "code": "TOKEN_BUDGET_EXCEEDED" } | Your plan doesn't include this capability, or you're out of tokens for the window |
403 | { "error": "Project not found or unauthorized" } | The project isn't yours and isn't shared with you through a team |
403 | { "code": "MODEL_NOT_ALLOWED" } | You asked for a model your plan can't run |
409 | { "code": "DUPLICATE_TASK" } | One of this type is already running for this project |
Duplicate submits are refused, not queued twice
There is at most one in-flight generation per (project, task type), enforced by a unique index in the database rather than by an application check — so a double-click, a retried HTTP request, or two scripts racing each other all land on the same answer:
{
"error": "A features generation is already in progress for this project. Please wait for it to complete.",
"code": "DUPLICATE_TASK",
"reason": "duplicate_task"
}
Nothing is created and no tokens are reserved — the insert fails before the reservation, so a 409 leaves no trace. queued, active and processing all count as in-flight; conversational agent sessions are exempt and may run concurrently. If a run dies without finishing, a sweeper runs every 15 minutes and frees the slot (an idle active row after 15 minutes, an idle queued row after 30), so a wedged task can't block that type forever.
Treat 409 as "back off and poll", not as an error to retry immediately.
Task types
| Task Type | What it generates |
|---|---|
features | Feature list from the project description |
personas | User personas for the project |
user-stories | User stories for a feature |
acceptance-criteria | Acceptance criteria for a user story, feature, or the whole project |
schema | Database schema from features |
pages | Page/screen definitions |
summary | Project summary and technical context |
ui | UI/wireframe generation for pages |
business-case | Business case report |
financial-analysis | Financial analysis for the business case |
design-system | Design system and branding |
sprint-plan | Sprint plan from the backlog |
derive-criteria-from-features · derive-criteria-from-pages · derive-criteria-from-page | Bulk-derive acceptance criteria across an existing spec |
agent-chat | A conversational agent turn |
There is no sections task type — sections are produced by the pages pipeline. Submit doesn't validate taskType, so a type the router doesn't recognise fails once the task starts rather than at submit time.
Model options
The modelId field selects which LLM to use, and your plan's allow-list is enforced server-side: ask for a model you're not entitled to and you get 403 MODEL_NOT_ALLOWED. When VibeMap is the one choosing the model, a disallowed pick is quietly downgraded to the cheapest model your plan can run instead of failing — so a plan-included feature never breaks on a choice you didn't make. On this endpoint an omitted modelId becomes gemini-3-flash, which every plan can run.
Monitoring progress
Poll for status
GET /api/tasks/s1a2b3c4-d5e6-7890-abcd-ef1234567890/status
Authorization: Bearer vm_your_token_here
Response 200 OK — camelCase, and note the field names are progress and phase, not progress_percentage / current_phase:
{
"id": "…",
"sessionId": "s1a2b3c4-d5e6-7890-abcd-ef1234567890",
"userId": "…",
"projectId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"status": "active",
"progress": 65,
"phase": "generating_features",
"message": "Generating feature 4 of 6…",
"steps": [],
"thinking": null,
"intermediateResults": {},
"result": null,
"createdAt": "2026-03-15T10:30:00Z",
"startedAt": "2026-03-15T10:30:04Z",
"completedAt": null,
"modelUsed": "gemini-3-flash",
"executionTime": null,
"retryCount": 0,
"maxRetries": 3,
"errorDetails": null
}
The read is scoped to the caller: a session ID that isn't yours can't be read through this endpoint at all.
Status lifecycle
queued --> active --> completed
| \-> error
| \-> cancelled
\------> partially_completed --> (continue) --> active
| Status | Description |
|---|---|
queued | Row created, waiting for a worker to pick it up |
active | Running — progress, phase and message update as it goes |
processing | Same as active; written by the bulk derive-criteria pipelines |
completed | Finished, results saved to your project |
error | Failed — message and errorDetails say why |
cancelled | Cancelled |
partially_completed | Ran out of time or budget with some results already saved. Resumable, and deliberately not treated as in-flight, so you can re-run that type |
Stop polling on completed, error or cancelled. Everything else means the run is still alive.
One synthetic value to know about: if a task sits in queued for more than three minutes and never starts, the status endpoint reports it as error with a message about the background worker. That's the endpoint telling you nothing is going to pick the job up — it's the usual symptom of a self-hosted instance with no Inngest worker running.
Polling strategy
Poll every 1–2 seconds during active generation. If you don't want to poll, subscribe to real-time updates instead — see below.
Real-time updates (Pusher)
As an alternative to polling, subscribe to Pusher for instant updates.
Channel and events
The channel is private: private-task-{sessionId}. A private channel needs an auth endpoint, and VibeMap's is /api/pusher/auth, which grants the subscription only if the signed-in caller owns that task. Subscribe to the bare task-{sessionId} and you will connect successfully and then receive nothing, forever — nobody publishes there.
The auth endpoint authorises from the browser session cookie, so this path is for first-party web clients. A PAT-authenticated script should poll the status endpoint instead.
| Event | Payload | Description |
|---|---|---|
task-update | { sessionId, status, progress, phase, message, thinking } | Progress update |
task-complete | { sessionId, status, progress, phase, message, tokens_used, cost, task_type, …counts } | Generation finished. The counts are per task type (features_count, personas_count, pages_count, …) |
task-error | { sessionId, status, error, message, errorType, recoverable, timestamp } | Generation failed |
task-partial-complete | { sessionId, status, progress, phase, message, canContinue, error } | Stopped with partial results that can be resumed |
task-cancelled | { sessionId, status, message } | Task was cancelled |
A second channel, private-task-progress-{sessionId}, carries a task-error event from the retry handler. Bind it too if you want every failure path.
Example (JavaScript / TypeScript)
import Pusher from "pusher-js";
const pusher = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, {
cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!,
forceTLS: true,
// Required: private channels are authorised per-subscription.
authEndpoint: "/api/pusher/auth",
});
const channelName = `private-task-${sessionId}`;
const channel = pusher.subscribe(channelName);
channel.bind("task-update", (data: { progress: number; message: string }) => {
console.log(`${data.progress}%: ${data.message}`);
});
channel.bind("task-complete", () => {
console.log("Generation complete");
channel.unbind_all();
pusher.unsubscribe(channelName);
});
channel.bind("task-error", (data: { error: string }) => {
console.error("Generation failed:", data.error);
});
channel.bind("task-partial-complete", (data: { canContinue: boolean }) => {
console.warn("Partial results saved; resumable:", data.canContinue);
});
Retrying and resuming
Retry a failed task
POST /api/tasks/s1a2b3c4-d5e6-7890-abcd-ef1234567890/retry
Re-runs the same generation on the same session ID. If the model's output was saved but the database write failed, the retry reuses that saved response instead of paying for a second generation — hasSavedResponse in the reply tells you which happened.
Response 200 OK:
{
"success": true,
"message": "Retry initiated (attempt 1/3)",
"retryCount": 1,
"maxRetries": 3,
"hasSavedResponse": true
}
Three attempts is the ceiling; a fourth returns 400 "Maximum retry attempts (3) exceeded". The budget gate runs before the status is reset, so a retry you can't afford returns 402 and leaves the task exactly as it was.
Resume a partially completed task
POST /api/llm-tasks/continue
Content-Type: application/json
{ "sessionId": "s1a2b3c4-d5e6-7890-abcd-ef1234567890" }
Only a task in partially_completed that recorded can_continue may be resumed; anything else returns 400. The re-reservation is sized to the remaining work, not the original target, so resuming after a budget cut costs only what's left. Still over budget returns 402, and the task stays partially_completed so you can try again after your window resets.
Both of these authenticate from the browser session, not from a PAT.
Cancelling a task
POST /api/tasks/cancel
Content-Type: application/json
{ "sessionId": "s1a2b3c4-d5e6-7890-abcd-ef1234567890" }
Response 200 OK:
{ "success": true, "message": "Generation cancelled" }
Only tasks in queued or active may be cancelled:
| Situation | Response |
|---|---|
| Cancelled | 200 { "success": true } |
No task for that sessionId | 404 |
| Task already finished | 409 |
Unlike most of this API, the reply here is load-bearing: the 200 means a
running task was actually stopped, not merely that the request was accepted.
This works for PAT-authenticated callers as well as browser sessions, so you can cancel from a script.
Cancellation stops the run — it does not merely label it. The
task_progress row is set to cancelled, a task/cancelled event terminates
the Inngest run (including fan-out children), in-flight provider streams are
aborted, and no further LLM calls are issued. Worst-case stop latency is roughly
two seconds.
Anything already generated is kept. Generations save incrementally and only
ever persist complete records, so cancelling leaves a smaller but fully valid
result rather than a half-written one. The run is marked resumable
(error_details.can_continue) and its project coverage reflects what was
actually produced.
A malformed body returns 422 with { "error": "invalid_request", "issues": [...] }.
Where to go next
- Read or write entities → CRUD endpoints
- Use the friendlier MCP wrapper from your IDE → MCP server overview