4.3 — Generation endpoints

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"
}
FieldRequiredNotes
projectIdYesThe project the results are written into
promptYesWhat to generate from. Max 100,000 characters — sized for the MCP codebase-digest flow. (projectDescription is accepted as a legacy alias)
taskTypeNoDefaults to features. See the table below
modelIdNoDefaults 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:

StatusBodyWhat 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 TypeWhat it generates
featuresFeature list from the project description
personasUser personas for the project
user-storiesUser stories for a feature
acceptance-criteriaAcceptance criteria for a user story, feature, or the whole project
schemaDatabase schema from features
pagesPage/screen definitions
summaryProject summary and technical context
uiUI/wireframe generation for pages
business-caseBusiness case report
financial-analysisFinancial analysis for the business case
design-systemDesign system and branding
sprint-planSprint plan from the backlog
derive-criteria-from-features · derive-criteria-from-pages · derive-criteria-from-pageBulk-derive acceptance criteria across an existing spec
agent-chatA 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
StatusDescription
queuedRow created, waiting for a worker to pick it up
activeRunning — progress, phase and message update as it goes
processingSame as active; written by the bulk derive-criteria pipelines
completedFinished, results saved to your project
errorFailed — message and errorDetails say why
cancelledCancelled
partially_completedRan 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.

EventPayloadDescription
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:

SituationResponse
Cancelled200 { "success": true }
No task for that sessionId404
Task already finished409

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