0.1.0 - DesignWithMe SDK

Applies to @mxt/designwithme-sdk v0.1.0-epic-PROD-8598.0.

DRAFT****

Overview

AI design services for your website. One API key in, events out: image
generation, product recommendations, price estimation, and a design assistant
that drives the first two — behind a single simple client.

import { createDesignWithMe } from '@mxt/designwithme-sdk';

const dwm = createDesignWithMe({ apiKey: 'your-api-key', environment: 'development' });

That is the whole setup. The first call signs in automatically; there is no
login step, no token to store, and nothing to refresh.

Get Started · SDK Reference · Live Examples

How the pieces fit

Everything hangs off one object. createDesignWithMe returns a client
conventionally dwm — and that client owns the session, the event bus, and
all the state. Under it sit four service namespaces, one per AI service:

              createDesignWithMe({ apiKey, environment })
                                 │
                                 ▼
  ┌───────────────────────────────────────────────────────────────┐
  │  dwm  —  DesignWithMeClient              one per application  │
  │  the session · the event bus · the activity log               │
  └───────────────────────────────────────────────────────────────┘
       │                 │                    │              │
       ▼                 ▼                    ▼              ▼
  dwm.images     dwm.recommendations    dwm.estimates     dwm.chat
       │                 │                    │              │
       ▼                 ▼                    ▼              ▼
 GenerationJob    RecommendationResult   PriceEstimate   ChatMessage[]
 a live handle:   SearchResult           a plain value:  ChatAction[]
 progress,        ranked SKUs            areas, a cost   a streamed reply
 result(),        + match scores         range, a        plus the actions
 refreshImages()                         breakdown       it triggered

The assistant is not a fifth service — it is a caller of the other ones. When
a conversation calls for a render or a product, the SDK runs that service
call for you and reports it as a chat:action:

  dwm.chat ──triggers──▶ dwm.images            'imageGeneration' action
           ──triggers──▶ dwm.recommendations   'recommendations' action
           ─────✗──────▶ dwm.estimates         never — see below

Price estimation is a direct API only. The assistant is not offered it as a
tool, so no chat:action will ever carry one.

There is no "project" object

The SDK has no project, room, or design entity that the services hang off —
nothing to create first, nothing to keep in sync. What ties calls together is
your own data: the room photo you host and the SKUs from your catalog.
You pass them to whichever service you want, in any order.

  a room photo URL ─┬─▶ dwm.images.generate({ roomImageUri, products })
  (yours, public)   ├─▶ dwm.estimates.create({ roomImageUri, roomType })
                    └─▶ dwm.chat.send(text, { attachments })

  catalog SKUs     ─┬─▶ dwm.images.generate({ products })
  (yours)           └─▶ dwm.recommendations.forProducts({ skus })

projectId on an image-generation request is an optional grouping tag on
the job record — a string you choose, for your own reporting. It creates
nothing, and no other service reads it.

A typical session

What a real end-user flow looks like end to end, and where the SDK does work
on your behalf:

 page load        │ const dwm = createDesignWithMe({ apiKey, environment })
                  │ nothing on the wire yet — creating a client is free
                  ▼
 first call       │ the SDK mints a session from your key, once, silently
                  │ and renews it in the background from then on
                  ▼
 user gives you   │ dwm.estimates.create({ roomImageUri, roomType })
 a room photo     │   → PriceEstimate: areas, cost range, confidence
                  │ dwm.recommendations.forProducts({ skus })
                  │   → ranked SKUs from your catalog, with scores
                  ▼
 user picks       │ const job = dwm.images.generate({ roomImageUri, products })
 products         │   job.on('progress', …)   ← narrate the wait
                  │   await job.result()      → the generated scene
                  ▼
 user talks to    │ await dwm.chat.start(); dwm.chat.send('warmer, please')
 the assistant    │   chat:delta   → the reply streams in, token by token
                  │   chat:action  → the SDK runs a generation or a
                  │                  recommendation and reports each step
                  ▼
 user refreshes   │ dwm.images.resume(jobId)  — the job never stopped
 the page         │ dwm.chat.resume(chatId)   — the transcript comes back
                  ▼
 user returns     │ dwm.chat.list()        → their past conversations
 next week        │ job.refreshImages()    → fresh URLs for old scenes

Two things to take from that. You never wait on the SDK to be ready
there is no connect step, and the session is established on the first call
that needs it. And the work outlives the page: generation jobs and chats
live on the service, so a refresh is a resume, not a restart.

🔑 One key, zero plumbing

Initialize with your API key. Sessions, token renewal, retries, and reconnects
are the SDK's problem — never yours.

Read more

📡 One event model everywhere

Every action reports the same way — subscribe with dwm.on(), await the
promise, or read the live state. Success and error handling you learn once.

Read more

🖼️ Image Generation

A room photo and a product list in, photorealistic scenes out. Progress events
while it works, a promise when it's done, resumable after a page refresh.

Read more

🛋️ Product Recommendations

Ranked, style-matched products from a cart or a free-text description — one
call, one score scale.

Read more

💰 Price Estimation

Room photo in, budget out — areas, cost ranges, per-component breakdowns, and
confidence scores. Imperial and metric.

Read more

💬 A design assistant that acts

The chat assistant doesn't just talk — it generates images and finds products
mid-conversation. The SDK runs those actions for you and tells you about each
step.

Read more

Service availability

The SDK is complete; the environments it can reach are not all deployed yet.
Getting Started carries the
current status — check it before you plan a rollout.


Getting Started

This guide takes you from zero to your first working DesignWithMe integration: install the SDK, create a client, and get product recommendations — in about ten lines.

The SDK is a complete client for the DesignWithMe AI services. You initialize it with your API key and call plain methods with plain objects; authentication, session management, request signing, polling, and WebSocket handling all happen inside. You never construct a URL, manage a token, or parse a wire payload.

Prerequisites

  • A DesignWithMe API key, issued by 3D Cloud. Keys are provisioned per client — contact your account representative to request one.
  • Node.js 18 or later, or any modern bundler/browser environment.
  • TypeScript is optional but recommended — every method, option, event, and result is fully typed.

Installation

The package is served from the 3D Cloud npm registry, so first point the @mxt scope at it. Add this to the .npmrc in your project root (or your user-level ~/.npmrc):

@mxt:registry=https://nexus.3dcloud.io/repository/npm-mxt/

Then install:

npm

npm install @mxt/designwithme-sdk

pnpm

pnpm add @mxt/designwithme-sdk

yarn

yarn add @mxt/designwithme-sdk

The package ships tree-shakeable ESM modules plus a minified single-file bundle (dist/designwithme-sdk.min.js) for direct <script type="module"> usage. Type declarations are included for both.

Create the client

Everything starts with createDesignWithMe:

import { createDesignWithMe } from '@mxt/designwithme-sdk';

const dwm = createDesignWithMe({
    apiKey: 'your-api-key',
    environment: 'development',
});

That's the whole setup. The first call you make signs in automatically; the SDK keeps the session alive from then on. There is no login step, no token to store, nothing to refresh.

Warning: Protect your API key

Your API key identifies your account. A key used from a browser is visible to anyone who opens dev tools — that is expected, and it is why browser keys are bound to an origin allowlist (see Authentication). Load the key from environment configuration rather than committing it, and don't reuse a key across environments or across trust boundaries — how many keys you need covers the shape.

Your first call

Ask for products that pair well with a sofa:

const { products } = await dwm.recommendations.forProducts({
    skus: ['SOFA-9412'],
    strategy: 'complementary',
    limit: 5,
});

for (const match of products) {
    console.log(`${match.sku} — ${(match.score * 100).toFixed(0)}% match`);
}

Every SDK result is a plain typed object. Every SKU here comes from your own catalog — your API key scopes everything to your account.

Listening instead of awaiting

Everything you can await, you can also observe. The SDK emits a named event for every action it takes, which is how UIs stay live without threading promises through components:

const off = dwm.on('recommendations:completed', ({ products }) => {
    renderCarousel(products);
});

dwm.on('error', (error) => {
    toast(error.message);
});

// later — every subscription returns its own unsubscribe
off();

Promises and events always agree: a call that rejects also emits its :failed event and the global error event. Pick whichever fits the moment — or use both. The full model is on the Events & State page.

Something longer-running: generate an image

Image generation takes seconds, not milliseconds, so it returns a job handle you can watch:

const job = dwm.images.generate({
    roomImageUri: 'https://example.com/uploads/living-room.jpg',
    products: [
        { id: 'SOFA-9412', quantity: 1 },
        { id: 'CHAIR-2210', quantity: 2 },
    ],
    prompt: 'Warm evening lighting, keep the rug visible',
});

job.on('progress', ({ status }) => showSpinner(status));

const images = await job.result();
render(images[0].url);

The handle tracks the job for you — submission, progress, completion — and dwm.images.jobs always holds the live list. See Image Generation.

Choosing an environment

The rule, permanently: pass the environment your key was issued for. Keys
are environment-specific and never work across one — a development key
authenticates only against development. environment defaults to
'production', so any other environment must be named explicitly.

Environment Purpose
development Integration and testing
cert Pre-production validation
production Live traffic (the default)

Each environment is fully isolated: separate keys, separate catalogs,
separate sessions. Nothing crosses over, so promoting an integration from one
to the next is a key change and an environment change, and nothing else.

Current rollout status

This is a status report, not a recommendation — it says which
environments exist today, not which one your integration should target. Once
an environment is deployed and you hold a key for it, point at it.

Environment Status as of this release
development Deployed.
cert Not yet deployed.
production Not yet deployed.

Ask your account representative where things stand before you plan a
production cutover; this page is only current as of the SDK version stamped
at the top.

Where to next

  • Authentication — what the SDK does with your key, and the auth-related errors you can encounter.
  • Events & State — the full event map and the queryable state that backs it.
  • Interactive Chat — the design assistant, including how it triggers the other services for you.
  • Integration Guide — production patterns: framework bindings, resuming work after a refresh, error UX.
  • Examples — runnable pages for every service; add ?mock=1 to run one without a key.

Authentication

You give the SDK an API key. That's the entire integration surface of authentication — everything below explains what the SDK does with it, what's configured on your account, and which errors can surface. There are no endpoints to call, no tokens to store, and no refresh logic to write.

const dwm = createDesignWithMe({
    apiKey: 'your-api-key',
    environment: 'development',
});

Keys are issued per client and per environment, so the two options above always travel together. Which environments are deployed today is a rollout status, not a rule about which one to use: point at the environment your key was issued for.

What the SDK does for you

On your first call, the SDK exchanges your API key for a short-lived session, then manages that session for the lifetime of the client:

  • Signs in lazily — nothing happens at createDesignWithMe; the session is established on the first call that needs it, and concurrent first calls share one sign-in.
  • Renews early — the session is renewed in the background at ~80% of its lifetime, so calls never pause to authenticate mid-flow.
  • Recovers automatically — if a call is ever rejected as expired, the SDK re-authenticates and retries it once before reporting an error.
  • Keeps chat flowing — every chat turn opens its own authenticated stream, so a long conversation picks up fresh credentials between turns without any involvement from you.

None of this is observable unless it fails — and failures surface the same way as every other SDK error (see below).

Concretely: your key is sent once, as an X-API-Key header, to the DesignWithMe auth service. What comes back is a short-lived session token plus a refresh token, and every subsequent service call carries the session token instead of your key. The key itself is never attached to an image-generation, recommendation, estimate, or chat request.

API keys

Keys are provisioned per client by 3D Cloud — contact your account representative. Each key is bound to:

Setting What it controls
Environment The key works only in the environment it was issued for.
Origin allowlist Browser calls are accepted only from your registered web origins (scheme + host + port).
Scopes Which services the key may use — assigned server-side when the key is issued.
Active status Keys can be deactivated by 3D Cloud or on request; an inactive key stops authenticating immediately.

How many keys do I need?

The unit is one key per client account, per environment. A single key covers every service your account is scoped for, and one key is enough for one application in one environment:

  your account
    ├── development key   ─── your staging site + your local dev origins
    ├── cert key          ─── your pre-production site
    └── production key    ─── your live site

You need a second key inside one environment only when a second surface has genuinely different exposure — most commonly a server-side integration alongside a browser one:

Surface Why a separate key helps
Browser (your website) The key ships in your JavaScript and is readable by anyone. It is protected by the origin allowlist, which only applies to browser requests.
Server (your backend) Requests carry no browser Origin, so the allowlist does not apply. A key used here is a genuine secret and should be treated as one.

Because a server key is not origin-bound, leaking one into shipped JavaScript hands out an unrestricted key. If you integrate on both sides, ask for one key for each so they can be rotated and revoked independently. If you only integrate in the browser, one key is the whole story — you are not expected to juggle several per app.

Where to keep your key

  • Browser key — inject it at build or runtime from environment configuration (VITE_DWM_API_KEY, an app-config endpoint, a template variable). It ends up in your bundle and that is fine; the origin allowlist is what constrains it. Don't hard-code it in a committed source file, so rotation is a config change.
  • Server key — your existing secret store (environment variables, Secret Manager, Vault). Never send it to the browser and never put it in a public repository.
  • Neither — end users never see or hold a key. The SDK does not authenticate your users; it authenticates your application. If you need per-user identity, that stays in your own system.

Scopes

Scopes are granted per service (image generation, recommendations, price estimation, chat). You don't request scopes in code — they're fixed server-side on the key, returned on the session, and enforced by the services. Calling a service your key isn't scoped for fails with the error code forbidden. If a service you expect to use is rejected, ask your account representative to check the key's scopes.

The origin allowlist

For browser integrations, the allowlist is the control that makes shipping an API key in front-end code acceptable: a key presented from an unregistered origin is refused, regardless of whether the key itself is valid.

Where it is enforced: in the DesignWithMe auth service, at the moment your key is exchanged for a session. The service compares the browser's Origin header against the list registered on that key and refuses with 403 ORIGIN_NOT_ALLOWED. It is a property of the key, not a network appliance in front of it — there is no WAF rule, CDN configuration, or firewall entry involved, and nothing to configure on your side beyond telling us which origins to register.

How origins get registered: by 3D Cloud, per key, on request — send your account representative the exact origins. There is no self-service screen today. Practical consequences:

  • Register every origin you serve from, including local development origins (e.g. http://localhost:5173) and any preview or staging hostnames.
  • Origins match exactly — scheme, host, and port all count. https://shop.example.com does not cover https://www.shop.example.com, and http://localhost:5173 does not cover :5174.
  • Register origins ahead of a launch. A new hostname that nobody told us about fails closed.
  • Server-side (Node) calls send no browser Origin and are not subject to the allowlist — see How many keys do I need?.

Warning: A rejected origin usually surfaces as a network error

The refusal is a 403 on a cross-origin request, so the browser blocks the response before the SDK can read it. In practice your code sees network_error, not origin_not_allowed — and the browser console shows a CORS failure. If a deployed site fails at the very first call with network_error while the same key works elsewhere, check the allowlist before you check your connectivity.

Authentication errors

Auth failures are ordinary DesignWithMeErrors — they reject the call that triggered them and emit the global error event, like every other failure.

Code Meaning What to do
invalid_api_key The key is missing, malformed, revoked, or wrong for this environment Check the key and the environment option together
origin_not_allowed The page's origin isn't on the key's allowlist Register the origin with your account representative. From a browser this usually arrives as network_error — see the note above
client_inactive The client account is deactivated Contact your account representative
forbidden The key lacks the scope for the service you called Have the key's scopes checked
rate_limited Too many authentication attempts Back off and retry; the SDK does not hammer sign-in
service_unavailable The service can't authenticate right now Retry later
network_error The service was unreachable — or a cross-origin refusal the browser hid Check the allowlist first, then connectivity
import { isDesignWithMeError } from '@mxt/designwithme-sdk';

try {
    await dwm.recommendations.search({ query: 'walnut credenza' });
} catch (error) {
    if (isDesignWithMeError(error) && error.code === 'invalid_api_key') {
        // configuration problem — not retryable
    }
}

A misconfigured key fails fast and consistently: the first call rejects, and every subsequent call rejects the same way until the configuration is fixed. The SDK never caches a failed sign-in.

Security best practices

  • Rely on the origin allowlist for browser keys — it's the mechanism designed for front-end use. Keep the allowlist tight; don't register origins you don't control.
  • Keep server keys out of the client. A server key is not origin-bound, so it is a real secret. If you integrate on both sides, use a separate key per side rather than shipping one key everywhere — see How many keys do I need?.
  • Don't commit keys. Load them from environment configuration at build or runtime.
  • One client per application. Create a single dwm instance and share it — it holds the session. Creating clients per-call throws away the managed session each time and re-authenticates needlessly.
  • Rotate on suspicion. If a key leaks, have it deactivated and reissued; deactivation takes effect immediately.

What's next


Events & State

Every action the SDK takes reports through one model: it returns a promise, it emits named events, and it updates queryable state. All three always agree — the event payload is the same object you can read from state, and a rejected promise always has a matching :failed event. Learn the model once and every service works the same way.

// 1. Await it…
const images = await dwm.images.generate(request).result();

// 2. …or subscribe to it…
dwm.on('generation:completed', ({ images }) => render(images));

// 3. …or read it later.
const finished = dwm.images.jobs.filter((job) => job.status === 'complete');

Subscribing

dwm.on(event, handler) registers a typed handler and returns an unsubscribe function — designed to drop straight into framework cleanup:

// React
useEffect(() => dwm.on('chat:message', (message) => setMessages([...dwm.chat.messages])), []);

// anywhere
const off = dwm.on('generation:progress', updateSpinner);
off(); // done listening
  • dwm.on(event, handler) — subscribe; returns () => void.
  • dwm.once(event, handler) — fire at most once; returns () => void.
  • dwm.off(event, handler) — remove a specific handler (the unsubscribe return is usually more convenient).
  • dwm.emitted(event) — a Promise of the event's next payload; handy for one-shot waits:
await dwm.emitted('chat:ready');   // resolves when the assistant is ready for input

Handlers are fully typed — the payload type is inferred from the event name. Throwing inside a handler never breaks the SDK or other handlers.

The event map

Names are namespaced domain:action. Facts are past tense (:completed, :failed); ongoing work reports as :progress.

Image generation

Event Payload When
generation:submitted GenerationJob The job was accepted and has an id
generation:progress GenerationJob The job's status advanced (queuedgenerating)
generation:completed GenerationJob Images are ready (job.images populated)
generation:failed GenerationJob The job failed (job.error populated)

Recommendations & search

Event Payload When
recommendations:completed RecommendationResult forProducts resolved
recommendations:failed { error } forProducts rejected
search:completed SearchResult search resolved
search:failed { error } search rejected

Price estimation

Event Payload When
estimate:completed PriceEstimate create resolved
estimate:failed { error } create rejected

Chat

Ships with the design assistant — the Interactive Chat page tracks its contract status.

Event Payload When
chat:statusChanged { current, previous, reason? } Any conversation-status transition
chat:ready { chatId } The active chat is ready for input
chat:failed { error } The active chat can't currently converse
chat:delta { messageId, delta, text } A fragment of a streaming assistant reply (text is the accumulated snapshot)
chat:message ChatMessage A message was added to the transcript (user, or a completed assistant reply)
chat:action ChatAction An assistant-triggered action changed status — see Interactive Chat

Global

Event Payload When
error DesignWithMeError Any operation failed, anywhere — one tap for all failure logging
activity ActivityEntry Anything happened — the umbrella feed of every event above

Promises and events always agree

The rules, which hold everywhere:

  1. Every operation returns a promise that resolves with the result or rejects with a DesignWithMeError. Nothing fails silently.
  2. Every outcome also emits: success emits the domain's :completed event; failure emits its :failed event and the global error event.
  3. Events are additive, not alternative. Awaiting a promise doesn't suppress its events; subscribing doesn't stop the promise from settling. Use either or both.
  4. Long-running work emits along the waygeneration:progress, chat:statusChanged — so a UI can narrate without polling anything.
  5. Aborts are not errors. Cancelling (via AbortSignal or job.cancel()) settles the promise with an abort rejection but does not emit error — you asked for it.

Queryable state

Events tell you when something changed; state lets you ask what things are at any moment. A component that mounts late doesn't need to have been listening — it reads current state and subscribes for changes going forward:

State Type Holds
dwm.images.jobs readonly GenerationJob[] Every job this client has submitted or resumed, live
dwm.recommendations.last RecommendationResult | undefined Most recent forProducts result
dwm.recommendations.lastSearch SearchResult | undefined Most recent search result
dwm.estimates.last PriceEstimate | undefined Most recent estimate
dwm.chat.status ChatStatus 'idle' | 'ready' | 'thinking' | 'failed'
dwm.chat.messages readonly ChatMessage[] The active chat's transcript, including action results — oldest-first, paged in by loadEarlier()
dwm.chat.hasEarlier boolean Whether the active chat has older messages left to load
dwm.chat.current { id, title, ... } | undefined The active chat
dwm.activity.log readonly ActivityEntry[] Everything the SDK has done this session, in order

State is read-only and updated before the corresponding event fires, so reading state inside a handler always sees the new value.

// The standard binding pattern: read state, re-read on events.
function useDesignWithMeChat() {
    const [messages, setMessages] = useState(() => [...dwm.chat.messages]);
    useEffect(() => dwm.on('chat:message', () => setMessages([...dwm.chat.messages])), []);
    return messages;
}

The activity feed

activity is the umbrella: one subscription that sees every event, normalized. It exists for logging, analytics, and debug panels — anywhere you want "what is the SDK doing?" without subscribing to each domain.

dwm.on('activity', (entry) => {
    // { at: 1755600000000, type: 'generation:completed', summary: 'Generation job job_x completed (1 image)', detail: {...} }
    analytics.track('dwm', entry);
});

console.table(dwm.activity.log);   // the same entries, queryable after the fact

Every entry: at (epoch ms), type (the event name), summary (human-readable one-liner), detail (the event payload).

Errors

All failures are instances of one class:

import { DesignWithMeError, isDesignWithMeError } from '@mxt/designwithme-sdk';

interface DesignWithMeError extends Error {
    code: string;        // stable, machine-readable — switch on this
    message: string;     // human-readable explanation
    status?: number;     // underlying HTTP status, when applicable
    requestId?: string;  // include when contacting support
}

Codes you can encounter, by category:

Category Codes
Configuration & access invalid_api_key, origin_not_allowed, client_inactive, forbidden
Bad input validation_error, unsupported_room_type, insufficient_input
Result quality low_confidence, generation_failed
Flow not_found, action_skipped
Load & availability rate_limited, service_unavailable, timeout
Transport network_error, unknown_error

Codes are stable API — new codes may be added, existing ones won't change meaning. Always branch on code, never on message text. Retry guidance per code lives in the Integration Guide.

dwm.on('error', (error) => {
    if (error.code === 'rate_limited') scheduleRetry();
    else if (error.code === 'network_error') showOfflineBanner();
    else log.error(error.code, error.requestId, error.message);
});

Configuration Reference

Everything createDesignWithMe accepts, plus the settings configured for your client account rather than in code.

Client options

import { createDesignWithMe } from '@mxt/designwithme-sdk';

const dwm = createDesignWithMe({
    apiKey: 'your-api-key',          // required
    environment: 'development',      // optional
    chat: {                          // optional
        onAction: (action) => action.proceed(),
    },
});
Option Type Default Description
apiKey string — (required) Your DesignWithMe API key. The client throws immediately if it's missing or blank.
environment 'development' | 'cert' | 'production' 'production' Which environment to target. Keys are environment-specific, so this must match the environment your key was issued for. Anything other than production must be named explicitly.
chat.onAction (action: ActionRequest) => void | Promise<void> auto-proceed Interception hook for assistant-triggered actions — inspect, then action.proceed() or action.skip(). Details on the Interactive Chat page.
fetch typeof fetch global fetch Custom fetch implementation. For test injection and non-standard runtimes; browsers and Node 18+ never need it.

The options object is read once at creation. To change configuration, create a new client — and note that a new client starts with fresh state (empty jobs, no transcript, new session).

Environments

Environment Purpose
development Integration and testing
cert Pre-production validation
production Live traffic (the default)

Each environment is fully isolated: separate API keys, separate catalogs, separate sessions. Nothing crosses over, and there is no way to address one environment's data from another.

Which of the three is deployed right now is a rollout status rather than a configuration rule — see Current rollout status. The service hosts behind each name are an internal detail; the environment name is the supported knob.

Account-level settings

These are configured on your client account by 3D Cloud, not in code. They shape what the SDK is allowed to do — if a capability seems missing, check here before debugging your integration.

Setting What it controls Surfaces as
Origin allowlist Which web origins may use your key from a browser origin_not_allowed — though a browser usually reports it as network_error, see Authentication
Scopes Which services the key may call forbidden errors from out-of-scope services
Catalog The product set behind recommendations and search The SKUs you get back
Assistant integrations Which actions the chat assistant may trigger (image generation, recommendations, estimates) Which chat:action types you observe
Pricing parameters Default cost assumptions for price estimation Estimate results when you omit pricePerUnitArea
Rate limits Request budget per key rate_limited errors

To change any of these, contact your account representative.

Warning: Browser keys ride the origin allowlist

The allowlist is what makes an API key in front-end code safe: the key alone is not enough — the request must also come from an origin you registered. Register every origin you serve from, including local development ones, and nothing else. It is enforced by the auth service on your key, not by any network layer you control; 3D Cloud registers origins on request. A server-side key sends no browser origin and so is not covered — see How many keys do I need?.

TypeScript

The package ships complete declarations; no @types package is needed. All request/response/event types are importable:

import type {
    DesignWithMeClient,
    DesignWithMeEvents,
    GenerationJob,
    GenerateImageRequest,
    RecommendationResult,
    PriceEstimate,
    ChatMessage,
    ChatAction,
} from '@mxt/designwithme-sdk';

DesignWithMeEvents is the full event map — useful for writing typed wrappers around dwm.on in your own framework glue.


Integration Guide

Patterns for taking a DesignWithMe integration to production: client structure, framework bindings, surviving page refreshes, error strategy, and a troubleshooting FAQ.

One client, shared

Create one dwm instance per application and share it. The client is the state — the sign-in, the job list, the transcript, the activity log. Multiple clients mean parallel sign-ins and split state, and there's never a reason for it.

// designwithme.ts — the one place the client exists
import { createDesignWithMe } from '@mxt/designwithme-sdk';

export const dwm = createDesignWithMe({
    apiKey: import.meta.env.VITE_DWM_API_KEY,
    environment: 'development',
});

Binding to a framework

The SDK is framework-agnostic by design: read state, re-render on events. Both halves matter — state gives late-mounting components the current picture; events keep it fresh. Every dwm.on returns its own cleanup function, which is the whole integration trick.

React

function useDwmEvent<T>(event: keyof DesignWithMeEvents, select: () => T): T {
    const [value, setValue] = useState(select);
    useEffect(() => dwm.on(event, () => setValue(select())), [event]);
    return value;
}

// usage
const messages = useDwmEvent('chat:message', () => [...dwm.chat.messages]);
const jobs = useDwmEvent('generation:progress', () => [...dwm.images.jobs]);

Lit / web components

class ChatPanel extends LitElement {
    private off?: () => void;
    connectedCallback() {
        super.connectedCallback();
        this.off = dwm.on('chat:message', () => this.requestUpdate());
    }
    disconnectedCallback() {
        this.off?.();
        super.disconnectedCallback();
    }
    render() {
        return html`${dwm.chat.messages.map(renderBubble)}`;
    }
}

Everything at once

For stores (Redux, Zustand, signals), the activity umbrella event is the single subscription that mirrors all SDK state into yours:

dwm.on('activity', () => store.set(snapshotFrom(dwm)));

Surviving a page refresh

The SDK's in-memory state is per-page-load, but the work survives — generation jobs keep running, and chats are durable with their transcripts held server-side. Persist two identifiers and re-attach on boot:

// persist as they're created
dwm.on('generation:submitted', ({ id }) => myState.saveJobId(id));
dwm.on('chat:ready', ({ chatId }) => myState.saveChatId(chatId));

// re-attach on boot
for (const jobId of myState.pendingJobIds()) dwm.images.resume(jobId);
if (myState.chatId()) await dwm.chat.resume(myState.chatId());

That's the complete refresh story: resumed jobs report through the same handles and events as new ones, and a resumed chat restores its transcript into dwm.chat.messages — recent history immediately, older messages on demand via loadEarlier(). (For chats you can even skip your own persistence — dwm.chat.list() recovers the user's conversations from the service.)

Error handling strategy

Every failure carries a stable code (full list). Branch on it, and let the category pick the response:

Category Codes Strategy
Configuration invalid_api_key, origin_not_allowed, client_inactive, forbidden Not retryable. Fix the key, allowlist, or scopes — fail loudly in development, alert in production.
User-fixable input validation_error, unsupported_room_type, insufficient_input, low_confidence Not retryable as-is. Turn into guidance: "try a wider photo", "pick a supported room type".
Transient rate_limited, service_unavailable, network_error Retryable with backoff. Don't retry in a tight loop — the SDK has already absorbed transient blips inside long-running work.
Flow not_found The referenced thing is gone (or was never yours — the services don't distinguish). Drop the stale id; for a chat, start a fresh one.

Two placements that work together:

// 1. A global tap for logging and generic UX — every failure passes through here.
dwm.on('error', (error) => log.warn('[dwm]', error.code, error.requestId ?? '', error.message));

// 2. Local handling only where the UX is specific.
try {
    const estimate = await dwm.estimates.create(request);
} catch (error) {
    if (isDesignWithMeError(error) && error.code === 'insufficient_input') return askForBetterPhoto();
    throw error;   // the global tap already logged it
}

Warning: Avoid wrapping every call

Resist a blanket try/catch around each SDK call with a generic toast. Decide per feature: degrade what's supplementary (a recommendations rail that fails just doesn't render) and surface what the user explicitly asked for (a failed search or generation deserves a real message). The error code is how you tell the difference programmatically.

Cancellation

Everything long-running accepts an AbortSignal, and job handles have cancel(). Wire component teardown to abort so background work doesn't outlive its UI:

const controller = new AbortController();
const job = dwm.images.generate({ ...request, signal: controller.signal });

// on unmount / navigation
controller.abort();

Aborts settle promises with an abort rejection and never emit error events — cancelling is not a failure.

Worked example: a minimal room designer

The pieces compose into a full experience with very little glue — chat drives the flow, and the other services are both directly callable and assistant-triggerable:

await dwm.chat.start({ title: 'Living room refresh' });

// The conversation IS the app: messages and action results render from one list.
dwm.on('chat:message', () => renderTranscript(dwm.chat.messages));
dwm.on('chat:action', (action) => {
    if (action.status !== 'completed') return updateActionChip(action);
    if (action.type === 'imageGeneration') showScene(action.job.images[0].url);
    if (action.type === 'recommendations') showRail(action.products);
    if (action.type === 'estimate') showBudget(action.estimate);
});

// Direct calls coexist with the conversation:
budgetButton.onclick = () => dwm.estimates.create({ roomType: 'furniture', roomImageUri, unitSystem: 'imperial' });

// The user types; everything above reacts.
input.onsubmit = (text) => dwm.chat.send(text);

Add the refresh-survival block and a chat:statusChanged banner, and this is production-shaped.

Performance notes

  • Calls are independent and safely concurrent — parallel estimates, several generation jobs, recommendations during a generation: all fine. The client coordinates authentication internally; there's no benefit to serializing SDK calls.
  • Don't refetch recommendations while they're on screen — results can reorder between calls. Fetch per context change, render from dwm.recommendations.last (why).
  • Generated image URLs are short-lived (~1 hour). Render immediately, and use job.refreshImages() for anything re-displayed later (details).
  • thinkingLevel is your latency dial for generation quality; start at the default and raise it only where quality demands it.

Troubleshooting FAQ

Every call fails with invalid_api_key.
The key, or the key/environment pairing, is wrong — a development key only works with environment: 'development'. Keys never work cross-environment.

Works locally, fails when deployed — usually as network_error, sometimes as origin_not_allowed.
The deployed origin isn't on your key's allowlist. Origins match exactly — scheme, host, and port. Because the refusal is a 403 on a cross-origin request, the browser blocks the response and your code sees network_error with a CORS message in the console; only a non-browser caller sees the origin_not_allowed code itself. Have the new origin registered before you go looking for a connectivity problem — see the origin allowlist.

I have one key — do I need a second one for my backend?
Only if you call the services from your backend as well. A browser key is constrained by the origin allowlist; a server key isn't origin-bound and is a real secret, so the two want separate lifecycles. One key per environment is otherwise the whole story — see How many keys do I need?.

One service fails with forbidden while the others work.
The key isn't scoped for that service. Scopes are per-key, server-side — ask your account representative.

The chat assistant never triggers actions.
Assistant integrations are enabled per account (settings). If chat answers but never acts, the integration isn't enabled for your client.

resume(jobId) fails with not_found.
Job records are kept ~30 days; older ids are gone. Drop stale ids when you see this code.

A generated image renders at first, then breaks later.
You persisted the image URL — it expires after about an hour. Persist the job id and call refreshImages(), or download the image to your own storage.

Two parts of my UI show different chat states.
Almost always two client instances. Share one dwm (see One client, shared) — the client is the single source of truth.


Image Generation

Generate photorealistic room scenes: supply a photo of the room and the products to place in it, and get back one or more generated images. Generation takes seconds, so the SDK gives you a job handle — submit, watch progress, await the result, and resume tracking after a page refresh.

const job = dwm.images.generate({
    roomImageUri: 'https://example.com/uploads/living-room.jpg',
    products: [
        { id: 'SOFA-9412', quantity: 1 },
        { id: 'CHAIR-2210', quantity: 2 },
        { imageUri: 'https://example.com/uploads/heirloom-lamp.jpg', name: 'Heirloom lamp' },
    ],
    prompt: 'Warm evening lighting, keep the rug visible',
});

const images = await job.result();
render(images[0].url);

dwm.images.generate(request)

Returns a GenerationJob handle synchronously — submission happens in the background, and the handle reports every step. You can attach listeners or await job.result() immediately; there is no gap to miss.

Request

Field Type Required Description
roomImageUri string yes URL of the room photo. Always the original room photo — see Iterating on a scene.
products ProductRef[] yes The products to place. At least one.
prompt string no Free-text styling and layout direction ("warm evening lighting", "rug stays visible").
aspectRatio string no "W:H", e.g. "3:2". Omit to match the room photo.
thinkingLevel 'minimal' | 'low' | 'medium' | 'high' no Quality/latency trade-off. Higher levels compose more carefully and take longer. Default 'minimal'.
projectId string no Groups the job and its output under one of your project identifiers.
signal AbortSignal no Aborting cancels submission or stops tracking — same effect as job.cancel().

Products

Two kinds of product reference, distinguished by which field you provide:

type ProductRef =
    | { id: string; name?: string; quantity?: number }         // a product from your catalog
    | { imageUri: string; name?: string; quantity?: number };  // a customer-supplied product photo
  • Catalog products are referenced by id; the service already knows what they look like. name helps composition quality — include it when you have it.
  • Custom products are anything with a photo — a customer's own furniture, an heirloom. Provide imageUri and a name.
  • quantity places multiples of the same product (default 1). Two accent chairs is { id: 'CHAIR-2210', quantity: 2 }.

Tip: Keep product counts practical

Composition quality degrades as the product list grows — roughly ten products is a practical ceiling for one scene. Placing a whole room? Prioritize the anchor pieces and let decor strategies fill in around them.

The job handle

interface GenerationJob {
    readonly id: string | undefined;      // set once submitted (see generation:submitted)
    readonly status: GenerationStatus;    // 'submitting' | 'queued' | 'generating' | 'complete' | 'failed'
    readonly images: GeneratedImage[];    // populated when complete
    readonly error?: DesignWithMeError;   // populated when failed
    readonly createdAt: number;           // epoch ms

    result(): Promise<GeneratedImage[]>;  // resolves on complete, rejects on failure
    refreshImages(): Promise<GeneratedImage[]>;
    cancel(): void;
    on(event: 'progress' | 'completed' | 'failed', handler): () => void;
}

interface GeneratedImage {
    url: string;      // display-ready image URL
    index: number;    // variant index, 0-based
    name?: string;    // stable identifier for the image
}

Status lifecycle

submitting → queued → generating → complete
                          ↘        ↘ failed

Statuses only move forward. complete and failed are terminal. The SDK tracks the job for up to five minutes of activity; a job that never reaches a terminal state in that window fails with a timeout error. Transient hiccups while tracking are absorbed silently — you only hear about real outcomes.

Watching progress

Job handles emit their own scoped events, and every job also reports through the client-level generation:* events (see Events & State) — handle events for the component that made the job, client events for anything else that cares:

job.on('progress', ({ status }) => setLabel(status === 'queued' ? 'Waiting…' : 'Generating…'));
job.on('completed', ({ images }) => render(images));
job.on('failed', ({ error }) => showError(error));

Cancelling

job.cancel() (or aborting the request's signal) stops the SDK from tracking the job and settles result() with an abort rejection. Cancelling is client-side — a generation already running may still finish on the service, but nothing will be reported for it. Aborts do not emit error events.

Resuming after a refresh — dwm.images.resume(jobId)

Generation outlives page loads. Persist job.id wherever you keep your own state (it's available from generation:submitted onward), and after a reload, re-attach:

// before: persist the id once the job is accepted
dwm.on('generation:submitted', ({ id }) => saveToMyState(id));

// after a refresh: pick the job back up — same handle API, same events
const job = dwm.images.resume(savedJobId);
const images = await job.result();

resume returns immediately with a tracking handle; if the job finished while you were away, it reports complete (or failed) on the first check. Unknown or expired job ids fail with not_found. Job records are kept for about 30 days.

Image freshness — job.refreshImages()

The url on a generated image is display-ready but short-lived (about an hour). Rendering it immediately is always fine. If you re-display a result much later — a gallery revisited the next day — refresh first:

const images = await job.refreshImages();   // same images, fresh URLs

For long-term storage, download the image or persist job.id and refresh on demand — don't persist the url itself.

Live state — dwm.images.jobs

Every job this client has created or resumed, in creation order, live:

const active = dwm.images.jobs.filter((j) => j.status !== 'complete' && j.status !== 'failed');
badge.count = active.length;

Multiple jobs run concurrently without any coordination on your part — each handle tracks independently, and client-level events carry the job so handlers can tell them apart.

Iterating on a scene

To refine a generated scene ("make it warmer", "swap the rug"), submit a new job with the original room photo and a prompt that restates the full direction so far — don't feed a generated image back in as the room photo. Re-generating from a generated image compounds visual loss; restating the accumulated prompt against the original photo is the intended pattern, and it's exactly what the chat assistant does when a conversation iterates on a room.

const prompts: string[] = [];
function refine(newDirection: string) {
    prompts.push(newDirection);
    return dwm.images.generate({ roomImageUri: originalPhoto, products, prompt: prompts.join('\n') });
}

Errors

Code Meaning
validation_error Bad request — missing room photo, empty products, malformed aspectRatio
not_found resume was given an unknown or expired job id
forbidden Your key isn't scoped for image generation
rate_limited Too many jobs — back off and retry
service_unavailable Generation is down; retry later
network_error Couldn't reach the service

Failures reject job.result(), set job.error, and emit generation:failed plus the global error event — the one model everywhere.


Product Recommendations

Two complementary calls over your product catalog:

  • forProducts — given products (a cart, a scene), return ranked recommendations under a chosen strategy.
  • search — free-text style search with optional attribute filters.

Both resolve from your own catalog — your API key scopes them; there's no catalog id to pass. Both return the same simple shape: ranked products with a match score where higher is always better (1 = perfect match, 0 = none), regardless of which call produced it.

const { products } = await dwm.recommendations.forProducts({
    skus: ['SOFA-9412', 'RUG-3307'],
    strategy: 'complementary',
    limit: 5,
});

dwm.recommendations.forProducts(request)

Request

Field Type Required Description
skus string[] yes The input products. An empty array short-circuits to an empty result — no call, no error.
strategy Strategy no How to recommend — see below. Default 'similar'.
limit number no Maximum results. Default 10.
threshold number no Drop results scoring below this (0–1). Omit for the service default.
signal AbortSignal no Abort the call.

Strategies

Strategy Returns
'similar' Substitutes and close variants of the inputs
'complementary' Complete-the-room pairings — what goes with the inputs
'decor' Decorative accents for the inputs
'priceAlternative' Cheaper or comparable-price alternatives, per input product

Result

interface RecommendationResult {
    products: Array<{
        sku: string;        // recommended product, from your catalog
        score: number;      // 0–1 match, higher is better
        sourceSku?: string; // which input this alternates for — priceAlternative only
    }>;
    strategy: Strategy;
}

Results are ranked best-first, never include the input SKUs, and may be shorter than limit (or empty) when little clears the bar — an empty list is a valid result, not an error.

const { products } = await dwm.recommendations.forProducts({ skus: cartSkus, strategy: 'priceAlternative' });
for (const alt of products) {
    console.log(`${alt.sourceSku} → try ${alt.sku} (${(alt.score * 100).toFixed(0)}%)`);
}

dwm.recommendations.search(request)

Style search in plain language — "mid-century walnut credenza with brass hardware" — with optional attribute filters.

Request

Field Type Required Description
query string yes The style description. Blank input short-circuits to an empty result.
filters SearchFilters no Attribute constraints — see below.
limit number no Maximum results. Default 10, capped at 50.
signal AbortSignal no Abort the call.

Filters

interface SearchFilters {
    color?: string[];
    brand?: string[];
    manufacturer?: string[];
    finish?: string[];
    family?: string[];
    style?: string[];
}

Within one field, values are OR (color: ['walnut', 'brown'] — either). Across fields, AND (that color and that style). Matching is case-insensitive.

There is no category filter — fold the category into the query text instead: searching "retro" within sofas works best as query: 'retro sofas'.

Result

interface SearchResult {
    products: Array<{ sku: string; score: number }>;   // ranked best-first, higher is better
}

Search has no pagination — it returns one ranked set, capped at 50. An empty products array means nothing in your catalog matched; show a no-results state, not an error.

const { products } = await dwm.recommendations.search({
    query: 'mid-century walnut credenza with brass hardware',
    filters: { style: ['mid-century'] },
    limit: 20,
});

Results are SKUs — hydrate for display

Both calls return SKUs and scores, not display data. Look prices, names, and images up in your own product system, and preserve the SDK's order when you render — the ranking is the product of the call:

const { products } = await dwm.recommendations.forProducts({ skus, strategy: 'similar' });
const details = await myCatalog.bySkus(products.map((p) => p.sku));
const ordered = products.map((p) => details.get(p.sku)).filter(Boolean);

Events & state

Events recommendations:completed / recommendations:failed, search:completed / search:failed
State dwm.recommendations.last, dwm.recommendations.lastSearch

Tip: Keep recommendations stable while they are on screen

Re-requesting with the same inputs can reorder results as the catalog evolves. Fetch once per context (a scene, a cart state) and render from dwm.recommendations.last — don't refetch on window focus or reconnect while the user is looking at them.

Errors

Code Meaning
validation_error Bad request — unknown strategy or filter field, out-of-range threshold
forbidden Your key isn't scoped for recommendations
rate_limited Too many requests — back off and retry
service_unavailable Service is down; retry later
network_error Couldn't reach the service

A common production pattern: recommendations degrade gracefully (a failed rail just doesn't render), while search failures surface to the user — search is their primary intent. Both are your call; the Integration Guide discusses it.


Price Estimation

Estimate a room's dimensions and project cost from a single photo. One call in, one estimate out: floor and wall areas, a cost range with a per-component breakdown, and a confidence score on every figure. Stateless — no job to watch, no session to manage.

const estimate = await dwm.estimates.create({
    roomType: 'kitchen',
    roomImageUri: 'https://example.com/uploads/kitchen.jpg',
    unitSystem: 'imperial',
});

showRange(estimate.cost.low, estimate.cost.high, estimate.cost.currency);

dwm.estimates.create(request)

Request

Field Type Required Description
roomType 'kitchen' | 'bath' | 'furniture' | 'decking' yes What's being priced. Anything else fails with unsupported_room_type.
roomImageUri string yes URL of the room photo.
unitSystem 'imperial' | 'metric' yes Imperial = square feet + USD. Metric = square meters + your chosen currency.
pricePerUnitArea number no Your cost assumption in the requested unit system (per sq ft or per sq m). Omit to use the pricing parameters configured for your account.
currency string no ISO 4217, metric only — imperial is always USD.
notes string no Free-text context ("galley kitchen, standard ceiling height"). Context raises confidence.
signal AbortSignal no Abort the call.

Warning: Price per unit area follows the unit system

The same number means very different budgets per square foot vs per square meter (1 sq m ≈ 10.76 sq ft). If you let users switch unit systems, convert your price assumption when you switch — don't reuse the number.

Result

interface PriceEstimate {
    roomType: RoomType;
    unitSystem: UnitSystem;
    floorArea: AreaEstimate;      // { value, unit: 'sqft' | 'sqm', confidence }
    wallArea: AreaEstimate;
    cost: CostEstimate;           // { low, high, currency, confidence }
    breakdown: BreakdownItem[];   // [{ component, low, high }]
}
  • Ranges, not points. cost.lowcost.high is the honest answer to "what will this cost?" — present it as a range.
  • Confidence is 0–1 on floorArea, wallArea, and cost. Low confidence means the photo left the model guessing — a wider shot or notes context improves it.
  • Breakdown components vary by room type (cabinets/countertops/appliances/labor for a kitchen; fixtures/tile/vanity for a bath; …). Render whatever comes back — don't hard-code the component list. Breakdown lines carry no per-line confidence; the range is the uncertainty.
for (const line of estimate.breakdown) {
    row(line.component, formatRange(line.low, line.high, estimate.cost.currency));
}

Both unit systems at once

Each call is independent, so showing imperial and metric side by side is just two concurrent calls:

const [imperial, metric] = await Promise.all([
    dwm.estimates.create({ roomType, roomImageUri, unitSystem: 'imperial', pricePerUnitArea: 45 }),
    dwm.estimates.create({ roomType, roomImageUri, unitSystem: 'metric', pricePerUnitArea: 45 * 10.7639, currency: 'EUR' }),
]);

Events & state

Events estimate:completed / estimate:failed
State dwm.estimates.last

The chat assistant can also produce estimates mid-conversation — those flow through chat:action and land in the transcript, and the underlying estimate still updates dwm.estimates.last.

Errors

Code Meaning What to do
validation_error Missing/invalid fields Fix the request
unsupported_room_type roomType isn't supported Offer only the four supported types
insufficient_input The photo couldn't be used — unreachable, or not enough visible room to measure Ask the user for a wider, clearer photo
low_confidence The model couldn't reach a publishable estimate from this photo Same remedy — better photo or added notes; there is no partial result
forbidden Your key isn't scoped for estimation Have the key's scopes checked
rate_limited Too many requests Back off and retry
service_unavailable Service is down Retry later

insufficient_input and low_confidence are user-fixable — treat them as prompts to improve the input, not as system failures.


Interactive Chat

A conversational design assistant that acts, not just answers: mid-conversation it can generate an image of the user's room or find products for it. The SDK runs the whole thing — the conversation transport, the persistent chat history, and the execution of every assistant-triggered action — and reports each step through the one event model.

Note: Rollout status

dwm.chat is implemented and reachable in the development environment — chats, transcripts, attachments, the streamed reply, and the assistant's action loop all run there today. What is still outstanding is the service side: the payload shape for assistant-triggered actions is not finalized, and the conversation endpoints are not yet deployed beyond development. This is a deployment status, not a caveat on the API described below.

const chat = await dwm.chat.start({ title: 'Living room refresh' });

dwm.on('chat:message', (message) => renderBubble(message));
dwm.on('chat:action', (action) => {
    if (action.type === 'imageGeneration' && action.status === 'completed') {
        renderInChat(action.job.images[0].url);
    }
});

dwm.chat.send('Show me this room with a mid-century refresh');

That's a complete chat integration with image generation included. No transport code, no message protocol, no orchestration.

Chats are persistent

A chat is not a fragile session — it's a durable conversation owned by your user. There is nothing to keep alive, nothing expires, and nothing needs ending: a chat started last week resumes exactly where it left off, history included.

dwm.chat.start(options?)

Creates a new chat and makes it the active one. Resolves when the assistant is ready for input.

Option Type Description
title string Optional display title for the chat list. No default is generated.

dwm.chat.resume(chatId)

Makes an existing chat the active one and loads its transcript — the assistant remembers everything. Persist dwm.chat.current.id in your own state and resume after a page load:

const existing = loadMySavedChatId();
if (existing) await dwm.chat.resume(existing);
else await dwm.chat.start();

render(dwm.chat.messages);   // most recent history, either way

resume loads the most recent page of history, not the entire conversation — a chat with a thousand messages resolves as fast as a new one. Older messages come in on demand:

// "Scroll up to load more"
if (dwm.chat.hasEarlier) await dwm.chat.loadEarlier();

loadEarlier() prepends the previous page to dwm.chat.messages and resolves once it's in. dwm.chat.hasEarlier is true while there is more history to fetch and false once the start of the conversation is loaded — so the snippet above is the whole of "load more". Both are safe on a chat you just started.

An unknown chat id fails with not_found — which is also what you see for a chat that isn't yours; the service doesn't distinguish.

dwm.chat.list(options?)

Your user's chats, most recently active first — the data behind a "Conversations" screen:

const { chats, nextCursor } = await dwm.chat.list({ limit: 20 });
// chats: [{ id, title, messageCount, createdAt, updatedAt }]
const more = await dwm.chat.list({ limit: 20, cursor: nextCursor });

nextCursor is absent on the last page; pass it back as cursor to keep walking. limit defaults to 50 and tops out at 200 — a larger value is clamped, not rejected. One client serves one active conversation at a time — list is for navigation, resume switches.

Two details worth knowing if you page deeply:

  • Ordering is by last activity. Sending a message moves that chat to the front, so a chat touched while the user is paging may appear twice or be skipped. Key your list rendering by id and the duplicate collapses.
  • messageCount counts stored messages, not rendered ones. The service records an assistant turn even when the reply came back empty, so the count can run slightly ahead of what's in the transcript. Treat it as an activity signal, not a total to paginate against.

Talking — dwm.chat.send(text, options?)

dwm.chat.send('What sofa would fit in this room?');

The user's message lands in the transcript immediately, and the assistant's reply streams: the SDK appends text to a pending assistant message as it arrives, firing chat:delta with each fragment and chat:message once the reply is complete. Render from state and a typing bubble falls out for free:

dwm.on('chat:delta', ({ text }) => pendingBubble.textContent = text);   // the growing reply
dwm.on('chat:message', () => renderTranscript(dwm.chat.messages));      // the finished message
Option Type Description
attachments ChatAttachment[] Files to send with the message — see Attachments. At most 3.

text may be omitted when attachments is present: sending a room photo with no caption is a normal request. Sending neither fails with validation_error.

Delivery is resilient by design

The service records your message before the assistant ever sees it, so a dropped connection can't lose it. There is no stream to resume — instead the SDK asks the service for whatever landed after the last message it saw, and delivers the finished reply as the same chat:message you'd have got from the stream. Your message is never re-sent, and you don't poll, retry, or check anything.

Two ways a reply can come back less than whole, both visible on the message itself:

  • truncated — the reply is real but cut short: the stream failed partway, or the model hit its output ceiling. The partial text is persisted and stays in the transcript.
  • blocked — the request was refused by safety screening, so text is empty and nothing was ever generated. Distinguishing this from an ordinary short answer is why the flag exists.

Neither is an error: the chat stays healthy and the user can simply say something else.

Attachments

The assistant can see — send a room photo alongside the text:

// Best practice: upload on file-pick, so send is instant.
const photo = dwm.chat.upload(fileInput.files[0]);   // returns an attachment handle immediately

dwm.chat.send('What would suit this room?', { attachments: [photo] });

upload(file) registers the file and transfers it in the background, returning a handle synchronously — call it the moment the user picks a file and by send time it's usually done. send waits for any transfer still in flight, so you never have to sequence the two yourself:

interface ChatUpload {
    fileId: string | undefined;          // set once the service has registered the file
    name: string | undefined;
    contentType: string;
    size: number;
    state: 'uploading' | 'ready' | 'failed';
    error: DesignWithMeError | undefined;
    ready(): Promise<ChatAttachment>;    // resolves when the transfer lands
}

You only need ready() if you want to show upload progress or catch a rejection at file-pick time; passing the handle to send is enough otherwise.

Limit Value
Attachments per message 3
File types An allowlist configured for your account (images, PDF, CSV, JSON, plain text)
File size A per-type ceiling, also configured for your account

A file outside the allowlist or over its ceiling is refused before any bytes are transferred — the limits are checked when the file is registered, not after a slow upload, so the rejection lands on upload.ready() (and on the send that carries it) rather than wasting the transfer. Exceeding three attachments, or sending a file whose transfer never completed, fails the send with validation_error.

Messages carry their attachments in the transcript:

interface ChatAttachment {
    fileId: string;
    name?: string;
    contentType: string;
    size?: number;   // absent on attachments read back from history
}

const url = await dwm.chat.fileUrl(attachment.fileId);   // fresh display URL

Always fetch the display URL at render timefileUrl returns a short-lived URL (about an hour) while chat history lives forever; never persist or cache the URL itself. contentType is the type the file was uploaded as, so a CSV attachment can be rendered as a table rather than a download link.

Files are reusable across messages and chats, including images the assistant itself generated in earlier turns:

const { files, nextCursor } = await dwm.chat.uploads.list({ limit: 20 });
// files: ChatAttachment + { state: 'pending' | 'ready', origin: 'upload' | 'tool', createdAt }
const reusable = files.filter((file) => file.state === 'ready');
dwm.chat.send('Use this one again', { attachments: [reusable[0]] });

uploads.list() pages newest-first with the same limit/cursor contract as chat.list(). Two fields are worth filtering on: origin distinguishes the user's own uploads from images the assistant generated, and state is 'pending' for a file whose bytes never arrived — attaching one fails the send, so skip them when offering files for reuse.

The transcript — dwm.chat.messages

interface ChatMessage {
    id: string;
    role: 'user' | 'assistant';
    text: string;
    attachments?: ChatAttachment[];   // files sent with, or produced by, this message
    actions?: ChatAction[];           // results of assistant-triggered work, attached in place
    pending?: true;                   // an assistant reply still streaming in
    truncated?: true;                 // the reply was cut short — see Delivery
    blocked?: true;                   // the request was refused by safety screening; text is empty
    createdAt: number;                // epoch ms
}

The transcript is live and ordered oldest-first — including everything resume and loadEarlier bring in, which the SDK normalizes for you. Render from state, refresh on chat:message:

useEffect(() => dwm.on('chat:message', () => setMessages([...dwm.chat.messages])), []);

Assistant actions

When the conversation calls for it — "show me", "what would go with this", "what would that cost" — the assistant requests one of the other services. The SDK executes the request itself: it runs the service call (an image generation is a real job, polled to completion), reports the result back to the assistant, and the assistant continues the conversation with the outcome in hand. All of it is narrated via chat:action events while each action's result attaches to the transcript message that produced it.

A single turn can go around this loop up to three times — the assistant can act on what an action returned and act again. Several actions requested together run in parallel, and each reports as it finishes rather than waiting on the slowest.

Actions failing never strands the conversation: a failed action is reported to the assistant as a failure, so its next reply can acknowledge and adapt rather than leave a silent gap. The same is true of an action type this SDK version doesn't implement — the assistant is told, and carries on.

Occasionally a turn ends after its actions without a further assistant reply: the user sent a new message while the work was running, or the turn used all three of its rounds. The action results still attach to the transcript and the chat returns to ready — nothing is lost, there just isn't a closing sentence.

interface ChatAction {
    id: string;
    type: 'imageGeneration' | 'recommendations';
    status: 'requested' | 'running' | 'completed' | 'failed';

    prompt?: string;                                  // imageGeneration — what the assistant asked for
    job?: GenerationJob;                              // imageGeneration — the live job handle
    query?: string;                                   // recommendations — what the assistant searched
    products?: Array<{ sku: string; score: number }>; // recommendations — the results

    error?: DesignWithMeError;                        // when failed
}

chat:action fires on every status change of the same action object:

requested → running → completed
                    ↘ failed
dwm.on('chat:action', (action) => {
    switch (action.status) {
        case 'running':   showThinking(action.type); break;
        case 'completed': renderResult(action); break;
        case 'failed':    renderApology(action.error); break;
    }
});

Three things worth knowing:

  • Actions flow through the regular services. An assistant-triggered generation is a real GenerationJob: it appears in dwm.images.jobs, fires generation:* events, and action.job is the same handle you'd get from dwm.images.generaterefreshImages(), result(), all of it. Recommendations update dwm.recommendations.last the same way.
  • Generated images become attachments. An image the assistant produces joins the user's files, so it turns up in dwm.chat.uploads.list() (with origin: 'tool') and can be sent back in a later message — "now try that one in grey" needs no re-upload.
  • Which action types you see is an account capability. Assistant integrations are enabled per client (see account-level settings). A type that isn't enabled simply never occurs.
  • The assistant cannot price a room. There are two action types, not three. Price estimation is a direct API (dwm.estimates) that your own UI calls — the model is never offered it as a tool, so no chat:action will ever carry one.

Intercepting actions

By default the SDK proceeds with every assistant-triggered action. To inspect first — confirm with the user, skip under conditions, attach analytics — provide chat.onAction at client creation:

const dwm = createDesignWithMe({
    apiKey,
    environment: 'development',
    chat: {
        onAction: async (action) => {
            if (action.type === 'imageGeneration' && !userAllowsGeneration()) {
                action.skip('User disabled image generation');
                return;
            }
            action.proceed();
        },
    },
});
  • The hook fires at requested, before the SDK acts. It may be async — the action waits.
  • Call action.proceed() to continue or action.skip(reason?) to decline. Returning without calling either proceeds (the safe default).
  • A skipped action reports failed with the code action_skipped — and, like any failed action, the decline is passed back to the assistant, so the conversation continues gracefully ("I wasn't able to generate that image…") instead of stalling.
  • The hook observes and gates; it doesn't rewrite the assistant's request.

Conversation status

Whatever the transport does under the hood — streamed replies, follow-up polling, retries — your visibility is one status value and its events:

State dwm.chat.status'idle' | 'ready' | 'thinking' | 'failed'
Transitions chat:statusChanged{ current, previous, reason? }
Sugar chat:ready, chat:failed
The active chat dwm.chat.current{ id, title, createdAt, updatedAt } | undefined

thinking is a turn in flight — the natural driver for a typing indicator. failed means the active chat can't currently converse (the reason says why); the history you already have stays readable, and resume or start recovers.

A rejected message is not a failed chat: an empty message, too many attachments, or a file that never finished uploading throws validation_error and leaves the status at ready, because the very next message can succeed.

Warning: Resuming mid-turn

If the page reloads while the assistant is waiting on an action, resume restores the transcript but does not pick the turn back up — the conversation is readable and you can send a new message, but that interrupted turn stays unfinished. Completing it on resume isn't built yet.

dwm.on('chat:statusChanged', ({ current }) => {
    typingIndicator.hidden = current !== 'thinking';
});

Errors

Code Meaning
not_found Unknown chat or file id — including one that belongs to someone else; the service doesn't distinguish
forbidden Your key isn't scoped for chat
validation_error A malformed request — an empty message with no attachments, more than 3 attachments, a file type or size outside your account's limits, a file whose transfer never completed, a bad pagination cursor
unauthorized The session couldn't be established or renewed — see Authentication
action_skipped Your onAction hook declined the action (surfaces on the action, not as a thrown error)
rate_limited Slow down
service_unavailable Chat is down; the SDK retries transient failures before reporting this
turn_failed The reply failed after the stream opened — the chat stays resumable
network_error Couldn't reach the service

Failures during a conversation don't lose it: a failed action reports on the action, a failed turn reports and leaves the chat resumable, and the transcript you've loaded stays readable throughout. A reply that arrived incomplete is not an error at all — see truncated and blocked.


Example Pages

Working, end-to-end example pages ship with every SDK release — one per
service, hosted alongside the SDK and pinned to the same version as these
docs. Each page is a complete SDK integration, exactly as documented here, and
every page's source is clean, commented, and copy-paste-ready. Each page also
shows the SDK's live activity feed, so you can watch every event fire as
the flow runs.

Open the example pages ↗

Example What it demonstrates
Image Generation dwm.images.generate → a live job handle: progress events while it works, the generated scene when it's done.
Product Recommendations dwm.recommendations.forProducts by strategy, plus free-text style search — one score scale for both.
Price Estimation dwm.estimates.create — imperial and metric side by side, ranges, breakdowns, and confidence.
Interactive Chat dwm.chat — a streamed reply, and the assistant triggering a generation or a product search mid-conversation.

The two URL parameters

Every example page reads two query parameters. Both are page behaviour, not
SDK features — nothing here has an equivalent in your own integration.

Parameter Default Effect
?mock=1 off Answer every API call from an in-browser mock instead of the real services. No API key needed.
?autorun=1 off Run the page's flow on load instead of waiting for you to press the button.

They combine: ?mock=1&autorun=1 opens a page that has already run itself,
which is the fastest way to see a finished result. Drop the parameter (or set
?mock=0) to go back to the live services.

?mock=1 — what it actually does

Mock mode replaces the browser's fetch for DesignWithMe URLs only. Requests
to /api/v1/** are answered by a mock that ships inside the page; every other
request passes through, and everything else on the page — including the SDK
itself — is completely unchanged and unaware that it is not talking to a
server.

  ?mock=0  (default)     page → SDK → fetch → glados → the real AI services
  ?mock=1                page → SDK → fetch → in-browser mock  (never leaves
                                              the tab)

Concretely, with ?mock=1:

  • No API key is needed and no network request leaves the browser. The
    mock answers POST /api/v1/auth/token with a fabricated session, so the
    SDK's sign-in succeeds and every later call is accepted.
  • The wire contracts are the real ones. The mock returns the same JSON
    shapes the services return, including chat's SSE streaming and the
    multi-request tool loop, so the SDK's parsing, event ordering, job polling,
    and state updates are all genuinely exercised.
  • Responses are canned and instant-ish. Every call takes a fixed ~350 ms;
    an image-generation job "completes" after ~2.5 s. Nothing is generated,
    scored, or priced — the numbers, SKUs, and images are fixtures.
  • The forms fill themselves in. The pages ship no real room photos or
    catalog ids, so in mock mode each required field adopts its own placeholder
    (an invented SKU, a storage.example.com URL). That is what makes
    ?autorun=1 work without a key.

So mock mode is the right way to read the code, watch the event sequence, and
see the shape of each result. It cannot tell you anything about real latency,
real image quality, your own catalog, or whether your key and origin are set
up correctly — for that, run live.

?autorun=1 — what it actually does

?autorun=1 presses the page's own primary button for you once the page has
loaded, using whatever is in the form. It exists so a page can be linked as a
finished demo, and so our screenshot and smoke tests can drive it.

In mock mode the form is pre-filled, so ?mock=1&autorun=1 runs unattended.
In live mode the fields start empty and required, so ?autorun=1 on its own
does nothing until you have filled the form in and supplied a key.

Running live

Live is the default: the pages call the real DesignWithMe services, so each
one needs an API key in its Connection panel. Two things have to line up
before a live run works:

  • The key must match the environment selected in the same panel. Keys are
    issued per environment and never work across one.
  • The page's origin must be registered on your key. The hosted example
    pages are served from an origin we have registered; a copy you host
    yourself, or one you run on localhost, is a different origin and will be
    refused until it is registered too. See
    Authentication — including why
    that refusal reaches your code as network_error rather than
    origin_not_allowed.

Running the examples locally

The examples live in the docs app (apps/sdk-docs/examples/ in the SDK
repository) and build into the hosted site:

pnpm --filter @mxt/designwithme-sdk-docs run dev
# → http://localhost:5175/examples/

The dev server hot-reloads the example pages as you edit them. Local origins
are not on the API key's allowlist, so add ?mock=1 when running locally
unless your own origin has been registered.