Skip to main content
Prompt-to-Playable Character

Technical tutorial

Build an animated GLB with the Meshy API

Developers1,861 words

This is a focused, reproducible walkthrough of the API pipeline behind Prompt-to-Playable Character. By the end you will have called Meshy directly with curl, understood why each request looks the way it does, and be able to trace the same flow through the app's TypeScript client and its durable Workflow SDK run. You need a Meshy API key.

1. Auth and key safety

Every Meshy call is a Bearer-token request over HTTPS:

Authorization: Bearer <MESHY_API_KEY>

Two rules, non-negotiable if you're building anything you'll deploy:

  • Never call Meshy from browser code. There is no CORS grant for this, and putting the key in client JS means it ships in the bundle.
  • Never prefix the variable with NEXT_PUBLIC_. In this repo the key is read once, inside server/meshy/client.ts, by a module gated by the server-only package — importing that module from a client component is a build-time error, not a runtime leak you find later. A visitor-supplied key follows the same rule: POST /api/credentials validates it server-side against Meshy's Balance endpoint and hands the browser back only an opaque, HttpOnly owner-token cookie, never the key itself.

Set the key as a plain server environment variable:

export MESHY_API_KEY="msy_your_key_here"
export MESHY_API_BASE_URL="https://api.meshy.ai"

2. Architecture in one paragraph

Live generation runs as a single durable Vercel Workflow SDK function, generateCharacterWorkflow (src/workflows/character-generation.ts, marked "use workflow"). Every Meshy call and every progress emission inside it is its own "use step" function, persisted as it completes. A typed Meshy client (src/server/meshy/client.ts) knows the wire format; route handlers under /api/orchestrations/* start the workflow, resume its approval webhook, and read its published snapshots. Everything below maps onto those pieces.

3. Two concepts, in parallel

The workflow starts two text-to-image tasks at once — direction A (faithful to the prompt) and direction B (a bolder variation) — so there's something to choose between before any geometry is spent:

curl -X POST https://api.meshy.ai/openapi/v1/text-to-image \
  -H "Authorization: Bearer $MESHY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "ai_model": "nano-banana-2",
    "prompt": "A nimble desert ranger with layered leather armor, a teal scarf, clear human proportions, and a neutral A-pose, stylized for an indie action game.\n\nFaithful direction: practical materials, clean silhouette, restrained details. Full-body single humanoid character concept, centered and isolated on a plain neutral background, entire body visible, clean A-pose, arms and legs clearly separated, no text, no frame, no scenery. Designed as production reference for a riggable 3D game asset.",
    "aspect_ratio": "1:1",
    "pose_mode": "a-pose"
  }'

The equivalent TypeScript, from src/server/meshy/client.ts:

export function buildConceptRequest(
  input: ConceptTaskInput,
): Record<string, unknown> {
  const direction =
    input.direction === "A"
      ? "Faithful direction: practical materials, clean silhouette, restrained details."
      : "Alternate direction: bolder costume shapes and material contrast while preserving the core character identity.";

  return {
    ai_model: "nano-banana-2",
    prompt: `${input.prompt}\n\n${direction} Full-body single humanoid character concept, centered and isolated on a plain neutral background, entire body visible, clean A-pose, arms and legs clearly separated, no text, no frame, no scenery. Designed as production reference for a riggable 3D game asset.`,
    aspect_ratio: "1:1",
    pose_mode: "a-pose",
  };
}

pose_mode: "a-pose" in the prompt and request body isn't a stylistic choice — the base-geometry step downstream needs separated limbs to detect a clean humanoid rig later. The response for each is a task ID:

{ "result": "0193a340-...task-id...-abcd" }

Poll both with GET /openapi/v1/text-to-image/<task-id> until each reaches SUCCEEDED and has an image_urls[0].

4. Approve one — the gate before geometry spend

This is the step a plain endpoint wrapper doesn't have. Everything before this point is two cheap images; everything after it spends real geometry credits. The workflow opens a webhook and pauses at AWAITING_CONCEPT once both concepts are ready. In the app, POST /api/orchestrations/[id]/concept with { "conceptId": "concept-a" } resumes it:

curl -X POST https://your-deployment/api/orchestrations/wrun_.../concept \
  -H "Content-Type: application/json" \
  -d '{ "conceptId": "concept-a" }'

If you're driving Meshy directly rather than through this app, the equivalent decision is simply: only pass the task ID of the image you actually want forward into the next call. The rejected direction is never converted into geometry.

5. Base geometry: image-to-3d with input_task_id, and why the profile matters

The approved concept's task ID becomes input_task_id on image-to-3d. Which fields go alongside it depends on the profile:

# fast profile
curl -X POST https://api.meshy.ai/openapi/v1/image-to-3d \
  -H "Authorization: Bearer $MESHY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_task_id": "<approved-concept-task-id>",
    "model_type": "smart-topology",
    "ai_model": "meshy-t2",
    "should_texture": true,
    "enable_pbr": true,
    "texture_resolution": "2k",
    "target_polycount": 15000,
    "pose_mode": "a-pose",
    "target_formats": ["glb"]
  }'
# quality profile
curl -X POST https://api.meshy.ai/openapi/v1/image-to-3d \
  -H "Authorization: Bearer $MESHY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_task_id": "<approved-concept-task-id>",
    "model_type": "standard",
    "ai_model": "meshy-6",
    "should_texture": true,
    "enable_pbr": true,
    "texture_resolution": "4k",
    "should_remesh": true,
    "topology": "quad",
    "target_polycount": 50000,
    "pose_mode": "a-pose",
    "image_enhancement": true,
    "remove_lighting": true,
    "target_formats": ["glb"]
  }'

fast targets meshy-t2 with smart-topology, 2K PBR textures, and a 15K polycount ceiling—quick turnaround for iteration. It deliberately omits image_enhancement and remove_lighting, because Meshy rejects those options for smart-topology. quality targets meshy-6 with standard topology remeshed to quad, 4K PBR textures, a 50K polycount, and both source-cleanup options. Both profiles set pose_mode: "a-pose" and request GLB output.

The app's request builder, buildPreviewRequest(conceptTaskId, profile) in src/server/meshy/client.ts, is the exact function generating both bodies above.

6. Rig: bind a skeleton to the base geometry

Rigging is a separate endpoint, and it takes the geometry task's ID as its input:

curl -X POST https://api.meshy.ai/openapi/v1/rigging \
  -H "Authorization: Bearer $MESHY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "input_task_id": "<image-to-3d-task-id>",
    "height_meters": 1.7
  }'
export function buildRiggingRequest(
  geometryTaskId: string,
): Record<string, unknown> {
  return { input_task_id: geometryTaskId, height_meters: 1.7 };
}

This step is where a non-humanoid or ambiguous mesh gets rejected — Meshy returns 422 if it can't recognize clear bipedal anatomy. If that happens, the textured GLB from step 5 is still yours; the run doesn't need to restart to keep it, you just can't animate it through this pipeline.

7. Prepare Idle, Walk, and Run

A successful Meshy rig already includes Walk and Run. The app then makes one animation-library request for the missing Idle action, producing the three-motion set shown in the player. The curated options live in src/config/animations.ts: combat-idle0, walk30, and run14. Walk is the default motion in the viewer.

curl -X POST https://api.meshy.ai/openapi/v1/animations \
  -H "Authorization: Bearer $MESHY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rig_task_id": "<rig-task-id>",
    "action_id": 0
  }'
export function buildAnimationRequest(
  rigTaskId: string,
  actionId: number,
): Record<string, unknown> {
  return { rig_task_id: rigTaskId, action_id: actionId };
}

The repository ships bun run verify:actions (scripts/verify-animation-actions.ts) to resolve the curated set against Meshy's animation-library reference. Run it with your own key before a release that depends on fixed IDs, because an upstream action catalog can evolve independently of the application.

8. Poll to a terminal state without leaking the key

All four task families expose the same status shape:

curl https://api.meshy.ai/openapi/v1/image-to-3d/<task-id> \
  -H "Authorization: Bearer $MESHY_API_KEY"

Terminal states are SUCCEEDED, FAILED, and CANCELED. A minimal poll loop:

while true; do
  status=$(curl -s https://api.meshy.ai/openapi/v1/image-to-3d/$TASK_ID \
    -H "Authorization: Bearer $MESHY_API_KEY" | jq -r .status)
  echo "$status"
  [[ "$status" == "SUCCEEDED" || "$status" == "FAILED" || "$status" == "CANCELED" ]] && break
  sleep 3
done

The browser never calls Meshy directly. A visitor submits a key to POST /api/credentials on the same origin; the server validates it through Meshy's balance endpoint, then keeps it out of JavaScript-accessible storage. The browser only polls GET /api/orchestrations/[id]. For a workflow run (id starting wrun_), that route resolves the run through workflow/api's getRun() and reads the latest snapshot from its stream; the response strips ownerKey and approvalToken before it reaches JSON. Inside the workflow, each stage polls with sleep("2s") (Fast) or sleep("3s") (Detail), capped at 180 attempts per Meshy stage and 120 for the two concept tasks.

9. Download before the signed URL expires

A successful task's response carries a signed .glb URL — the exact field varies by endpoint family, which is why normalize.ts's extractGlbUrl() checks several documented shapes rather than assuming one. Download it immediately:

curl -L -o character-animated.glb "<signed-glb-url-from-task-response>"

This project never asks the client to supply a raw provider URL: GET /api/assets/download takes a session ID and an asset name (animated, rigged, refined, preview, or fbx), looks up the current URL server-side, and refuses to stream from outside an explicit host allow list. Meshy returns expires_at on completed tasks, which the app trusts directly; when that is absent, the app treats the URL as stale after ASSET_URL_TTL_MS—71 hours, leaving a one-hour safety margin inside the documented three-day non-enterprise retention window.

Troubleshooting: real failure modes

This maps directly to src/server/meshy/errors.ts, the single place this project normalizes every failure Meshy or the network can produce.

HTTP / conditionCauseFix
401 / 403Missing or invalid API keyMESHY_UNAUTHORIZED — the app disables live generation entirely and points at the showcase rather than retrying an auth failure. Check MESHY_API_KEY, or re-enter a visitor key beginning with msy_.
402Insufficient creditsMESHY_INSUFFICIENT_CREDITS — not retryable automatically. Top up, or use the showcase character, which needs no live credits.
422Meshy could not process the input — most commonly rigging rejecting an unclear or non-bipedal meshMESHY_UNPROCESSABLE. Retry with a prompt that has clearly separated limbs and a neutral A-pose. The textured geometry from the prior stage is preserved either way.
429Rate limitedMESHY_RATE_LIMITED. The client already retries GET requests automatically with exponential backoff (up to 3 attempts, honoring Retry-After). Task-creation POSTs are not auto-retried, since Meshy documents no idempotency key and a blind retry risks a double charge.
500 / 502 / 503 / 504Meshy-side server errorMESHY_SERVER_ERROR, also auto-retried for GET. If it persists on a POST, retry the specific stage once Meshy recovers — upstream task IDs are untouched.
Task reaches FAILED or CANCELEDThe task itself failed after being accepted, distinct from an HTTP error on the create/poll callMESHY_TASK_FAILED / MESHY_TASK_CANCELED. Read the task's own error payload; retry from the failed stage's upstream task ID rather than restarting the whole run.
Signed URL 404s or times out on downloadThe URL has expiredASSET_URL_EXPIRED. Re-fetch the task object by ID for a fresh signed URL.
Request times out client-sideNetwork stall, not a Meshy responseThe client aborts after 30 seconds (REQUEST_TIMEOUT_MS) and treats that as a 504-equivalent, retryable failure.
Prompt rejected before any Meshy callLocal validation caught a non-character or malformed promptINVALID_PROMPT. Describe one humanoid character in 10–600 characters; presets in src/config/prompts.ts are written for the rigging constraints.

Fork it

Everything above is one Meshy account and a handful of request builders away from running in your own project. Clone the repository, set MESHY_API_KEY for private development or use the public bring-your-own-key gate, run bun run dev, and watch the same requests through the developer panel—including the approval webhooks you trigger against a local run.