WorkspaceBuilder
The WorkspaceBuilder model — fields, relations, and API.
Transport: inline on the shared /ai-v2/chat transport (REST + WebSocket), routed to by the supervisor — same as Units · Graph: interview → plan → review (human-in-the-loop), then a deterministic build · Flag: AI_V2_ENABLED · Routing key: workspace_builder
The builder is a normal inline chat agent. It runs on the shared
/ai-v2/chattransport exactly like Units — one socket, one conversation,assistant:*events, one shared history. A build request routes to it (supervisor pick or a mid-flow pin), it interviews and proposes a plan behindassistant:confirmation_requiredgates, and on approval it builds the workspace, streamingassistant:tool_start/assistant:tool_resultper step. There is no separate/ai-v2/buildersocket and nobuilder:*events — that standalone channel was retired. See INTEGRATION.md §5 for the canonical wire contract.
Its human-in-the-loop pauses ride the one discriminated assistant:confirmation_required
gate every inline agent shares:
| Gate | Emitted as | Answered by |
|---|---|---|
| interview questions | assistant:confirmation_required { type: 'questions', questions } | assistant:send { answers } (a fresh message) |
| plan review | assistant:confirmation_required { type: 'plan', plan } | assistant:confirm { approved, plan? } / free-text assistant:send to revise |
| publish offer (post-build) | assistant:confirmation_required { type: 'share', plan } | assistant:confirm (publish) / assistant:reject (keep private) |
History persists. The plan and the questions are committed into the conversation
transcript as typed content blocks (workspace_plan / builder_questions) — appended
by a pre-interrupt graph node so they survive the pause — so a reload of
GET /ai-v2/conversations/:slug/messages re-renders the plan card / question buttons, not
just the flattened summary line.
PRD
Business problem
Setting up a CRM schema — objects, fields, relations, views — by hand is slow and requires knowing the data model. This lets a workspace owner get a complete, correctly-typed, messaging-ready workspace from a plain description, with a review gate so nothing is written until they approve.
User stories
- As a workspace owner, I want to describe the system I need and review an editable plan before anything is created, so that I get a complete, correctly-structured CRM workspace without manually modeling objects, fields, and relations.
Success criteria
- Nothing is written before the user approves the plan (
propose_plan→plangate →assistant:confirm); the plan is always editable at that gate. - On approval the whole plan is built to completion — every object, its fields, relations, a table view per object (plus a kanban when the object has a select field), sample data, and one folder — guaranteed by a deterministic completion pass even if the model stops early.
- Person-like objects (Lead / Client / Contact / …) come out messaging-ready (FR-430 fields injected), so WhatsApp / Facebook / Instagram / TikTok leads auto-create.
How it works
High-level overview — enough to understand the feature without reading the code.
Summary
Plan size follows the request. When the user lists the objects and fields they
want, that list is authoritative — the agent builds exactly it, without trimming or
padding. The "3–6 objects" default applies only when the user described a goal rather
than a list ("build me a clinic system"). propose_plan is bounded by
BUILD_MAX_OUTPUT_TOKENS; when it overflows, the retry is steered smaller and the
review gate says so (PLAN_REVIEW_REDUCED_MESSAGE) instead of presenting the
reduction as a complete plan. There is no relation to a system user and
no person field type, so a "prepared by"-style field becomes either the record owner or
a real object to relate to — never a text field holding a name.
The agent interviews first (ask_questions), capped at three rounds per build — the
count accumulates across the answer round-trips of one build and resets when a new build
starts, so a second build in the same conversation still gets to interview. After the cap
ask_questions is unbound and the model must propose. It then calls propose_plan; the
LangGraph
pauses at an interrupt() review gate that surfaces the full plan for the user to
review/edit (no writes yet).
The user reviews a skeleton; the detail is designed after they approve.
propose_plan returns only the skeleton — the template name, the objects with a
one-line description each, and the relations that link them. That is the whole plan
gate: objects have an empty fields list, no sample_rows, and there are no
automations. A client rendering the plan has to present it as the outline it is.
PlanEnrichmentService fills that in at the head of the build
(WorkspaceBuilderAgentService.finishBuild, inside the build lock, before the first
write): each object's fields (one small call per object, all concurrent), then its
example rows plus the plan's automations (concurrent again; automations are one call for
the whole plan, because a rule spans two objects). What BuilderCompletionService
builds — and what the publish offer and the closing summary describe — is that enriched
plan, not the skeleton that was approved.
Two separate reasons for this shape:
- Why the skeleton is one call. Fields and rows for every object put
propose_plannear the 16k output cap, and at the ~50 tok/s a reasoning model sustains that is minutes of blank screen. The skeleton is ~1k tokens. - Why enrichment waits for approval. It is a model call per object. Running it before the gate put that cost between the user's last answer and the plan appearing — the one moment in the flow where they have nothing to do but wait — and spent it designing a workspace they had not yet agreed to build. After approval they are watching a build run, which is a place a progress checklist belongs.
Every wave reports a plan_fields / plan_examples step through onPhase, so the build's
checklist opens with a per-object design pass before the first create_object. A wave that
fails costs that object its fields rather than the whole build; a cancelled turn
propagates instead, so stopping never yields a half-designed workspace.
Deciding the system is judgment and runs on AI_BUILD_*; filling in known-shape detail is
execution and runs on AI_BUILD_FAST_* (unset ⇒ falls back to the build model).
assistant:confirm resumes the graph with the approved
(possibly edited) plan via Command({ resume }) — the graph ends there. The build then
runs deterministically from the plan with no model (BuilderCompletionService): it
executes the whole plan directly — objects → their fields → a table view (plus a kanban
when there's a select field), anchored on a real field of the object → sample data, then
relations → automations → one folder. That makes a build seconds instead of minutes, free
of AI cost, and immune to the model stalling or hitting the graph recursion limit — the
LLM is used only for the interview + plan, which is why the plan carries every detail a
build needs (typed fields WITH their options, sample rows, relations, automations). After a
build the agent offers to publish the workspace to the object-template catalog (a
share gate; pending → super-admin publishes), and published templates can be installed
into any live workspace.
Flow
Steps
- A build request on
/ai-v2/chatroutes toworkspace_builder(the supervisor picks it, or a pending builder gate on the thread pins it).AiGraphServiceannounces it withassistant:agent { agent: 'workspace_builder' }. WorkspaceBuilderAgentServiceruns the graph with the model bound only toask_questions+propose_plan, so it can interview and plan but not write.- Interview first: the model calls
ask_questions;prepareQuestionscommits the questions as a display block, then thequestionsnodeinterrupt()s — surfaced as thequestionsgate. The client answers withassistant:send { answers }(or free text); the model keeps interviewing until it understands the business, bounded by a round cap (MAX_QUESTION_ROUNDS, then it must plan). - The model calls
propose_plan;prepareReviewvalidates it and commits it as a display block, then thereviewnodeinterrupt()s — surfaced as theplangate. The gate carries the skeleton: objects and relations, no fields, rows or automations. - (Optional loop) A free-text
assistant:sendwhile the plan is up →provideFeedback→ resume{ approved:false, feedback }→ the model re-proposes → pauses again. assistant:confirm { plan? }(an edited plan is validated/clamped bysanitizePlan) →approve→ resume{ approved:true, plan }; the review gate flips the phase and the graph ENDS (no model write-loop).PlanEnrichmentServicedesigns the approved skeleton's detail — each object's fields, then its example rows and the plan's automations — concurrently, reportingplan_fields/plan_examplessteps onto the build's own progress channel.BuilderCompletionServicebuilds that enriched plan deterministically, with no model: all objects first (so a field's smart_catalog source and a relation's endpoints exist), then per object its fields → a table view anchored on a real field (plus a kanban when the object has a select field) → its sample data, then the relations → automations → one folder. It reuses the same write tools through the sameGuardedToolExecutor(policy guard + idempotency), tracking each field's slug so automations resolve by slug. Every write emitsassistant:tool_startwhen it starts andassistant:tool_resultwhen it completes, so the client fills a live checklist — fast (seconds, no LLM round-trips).create_objectis idempotent (a retry reuses an existing object rather than making a "Lead 2" variant), so re-running a build replays each write instead of duplicating it.- The turn ends on a
sharegate:assistant:confirmation_required { type: 'share', plan }with the build summary.assistant:confirmsnapshots the built objects and saves them as a pending catalog template;assistant:rejectkeeps it private. Either way the offer is consumed once.
Key components
- Run facade —
services/workspace-builder-agent.service.ts— the inline agent: exposesrun/runStream+resume/resumeStream+pendingGate/hasPendingShare(the same interface Units implements), dispatches the four moves (start / answer / feedback / approve) off the thread's pending gate, meters credit, holds the pinned checkpointer. - Graph —
graph/builder.graph.ts— nodesagent/prepareReview/review/prepareQuestions/questions; the model is bound only toask_questions+propose_plan, never write tools — approval ENDS the graph. Each gate is split so the display block commits BEFORE the interrupt. Plan tool + schema ingraph/propose-plan.tool.ts. - Deterministic build —
services/builder-completion.service.tsexecutes the approved plan directly (no model), driving theassistant:tool_start/assistant:tool_resultstream. - Prompt —
workspace-builder.prompt.ts(interview-first, plan-first, FR-430). - Share —
services/workspace-template-sharing.service.ts(snapshot → pending template)services/builder-share.store.ts(the Redis-held publish offer a bare confirm/reject resolves).
FRD
Per-capability functional requirements — the write tools this agent exposes (the agent
analogue of a model's fields). Location: workspace-builder/tools/ (plus propose_plan
in graph/):
ask_questions— interviews the user BEFORE planning, like a business consultant: authors a batch of questions that understand the problem they want to solve first (what is painful/manual today, what they need to track, a good outcome), then the specifics (objects, what each holds, how they work), each with button options; intercepted by the discovery gate, never executed. The goal is a workspace that SOLVES the problem, not a generic schema. The user's answers resume the interview until the model understands enough to plan, bounded by a round cap.propose_plan— authors the plan's skeleton (template name, objects with a one-line description each, and the relations linking them) for review; intercepted by the review gate, never executed. Fields,sample_rowsandautomationsare deliberately NOT in its schema — the model volunteering them is dropped bytoPlan, so a fat call and a small one cannot both "work" and quietly bring the latency back.create_object— creates a CRM object with default fields; if the object is person-like (is_contact) it injects the FR-430 messaging block.create_field— adds one typed field (number / currency / date / select / serial_number / smart_catalog / calculation / …); backfills smart-field defaults; rejects the relation type.create_relation— links two objects (belongsTo= single FK,hasMany= junction).create_view— creates a table or kanban view (kanban groups by a status/select field).seed_sample_data— inserts up to 10 example records so objects aren't empty; fed the approved plan'ssample_rows, so the preview grid is what actually lands.create_automation— turns one plan automation into a real, active workflow (WorkflowService.create→toggle), so the rule the user reviewed runs. Exposes a self-contained subset — triggersrecord_created/status_changed/field_changed, actionsupdate_status/update_field/create_record— so every rule references only objects/fields this build created (no messaging session, webhook, or schedule a fresh workspace lacks).create_folder— creates one sidebar folder named after the template and moves the built objects into it; the mandated final step / completion signal.
FR-430 messaging-ready contacts (constants/messaging-contact-fields.ts) inject four
typed fields onto any person-like object: whatsapp_number, provider_id,
session_name, source.
HLD
Frontend
The same chat client as Units — there is no separate builder screen. On a build turn it
receives assistant:agent { agent: 'workspace_builder' }, then resolves each
assistant:confirmation_required by its type: render questions as buttons (answer with
assistant:send { answers }), render plan as an editable preview (confirm with
assistant:confirm { plan? } or revise with a free-text assistant:send), fill a live
checklist from the assistant:tool_start / assistant:tool_result stream during the build,
and answer the post-build share gate with assistant:confirm / assistant:reject. On
reload, the plan and questions re-render from their persisted content blocks (see
INTEGRATION.md §5). See INTEGRATION.md §5 for the full inline contract.
API
Inline on /ai-v2/chat (REST POST …/messages + …/confirm, and the WebSocket
assistant:* events), gated by AI_V2_ENABLED. The share → publish → install lifecycle is
REST on the object-template catalog. Both are summarized under API.
Database changes
This is the write-heavy agent: on approval it creates CRM objects (each a physical
table), their fields (columns), relations (FKs / junction tables), views, a
sidebar folder, and up to 10 sample rows per object. Graph state persists to the
LangGraph checkpoint tables; sharing writes a pending_review row into the main-DB
object-template catalog.
LLD
Tables
BuilderStateAnnotation (graph/builder.graph.ts):
| Field | Type | Notes |
|---|---|---|
messages | BaseMessage[] | append reducer; also carries the workspace_plan / builder_questions display blocks |
approved | boolean | phase flag the review gate flips; true ⇒ the graph ENDS (build runs outside it) |
questionRounds | number | interview rounds, capped at MAX_QUESTION_ROUNDS = 3 |
Persisted to a workspace-pinned Postgres checkpointer, keyed
thread_id = tenantDatabase:workspaceId:conversationSlug — the SAME chat conversation
thread Units uses, so the build shares one history and one transcript with the rest of the
chat. The build's objects/fields become real CRM tables/columns.
Services / nodes
agent—callModel: binds[askTool, proposeTool](or[proposeTool]past the interview cap) — never write tools; building happens after approval, outside the graph.prepareReview/review—prepareReviewvalidatespropose_plan, closes the tool call, and commits the plan as a display block (its own super-step);reviewreads that block,interrupt()s with aPlanReviewRequest, and resumes on aPlanReviewDecision { approved, plan?, feedback? }— approval routes to END.prepareQuestions/questions— the discovery counterpart:prepareQuestionscommits the questions block,questionsinterrupt()s and folds the resumed answers into a human turn.- Facade & helpers —
WorkspaceBuilderAgentService,BuilderCompletionService,WorkspaceTemplateSharingService,BuilderShareStore.
Validators
proposePlanSchemavalidates the model's plan args;sanitizePlanvalidates/clamps a user-edited plan on confirm.- Write tools carry
object.create/relation.create/workflow.createpermissions (constants/builder-permissions.ts). - The pinned checkpointer is required (the human pause cannot survive without it); it returns a retryable 503 if unreachable.
API
The builder has no transport of its own — it is driven entirely through the inline chat
contract documented in INTEGRATION.md §5 (assistant:send / assistant:confirm /
assistant:reject over the WebSocket, or POST …/messages + …/confirm over REST). The
gate payloads it emits are the AiV2Gate arms (questions / plan / share); their
plan / questions bodies use the payload types below.
Payload types
interface BuilderPlan {
template_name: string;
category: string;
description?: string;
objects: BuilderPlanObject[];
relations: BuilderPlanRelation[];
automations?: BuilderPlanAutomation[]; // optional business rules, previewable + prunable
}
interface BuilderPlanObject {
name: string;
description: string;
is_contact?: boolean; // person-like → FR-430 messaging block injected at build
fields: BuilderPlanField[];
sample_rows?: SampleRow[]; // 0–3 example records for the preview grid; keys are FIELD NAMES
}
// A JSON-scalar map keyed by field NAME (slugs don't exist until built). These are the
// SAME rows seed_sample_data writes on build — the preview grid shows what actually
// gets seeded, not invented placeholder data. Edits survive the confirm.
type SampleRow = Record<string, string | number | boolean | null>;
interface BuilderPlanField {
name: string;
label: string;
type: string; // CRM field-type slug (text, number, currency, date, select, serial_number, smart_catalog, calculation, …)
required?: boolean;
formula?: string; // calculation field, e.g. "{quantity} * {unit_price}"
source_object?: string; // smart_catalog field: NAME of the object it picks from
}
interface BuilderPlanRelation {
name: string;
label: string;
relation_type: string; // "belongsTo" (single FK) | "hasMany" (junction)
source_object: string; // referenced by object NAME (slugs don't exist until built)
target_object: string;
}
// One business rule, rendered as a human-readable "when … → …" card the user can
// edit or drop. All references are by object/field NAME (resolved to slugs on build).
// On build it becomes a real, active workflow. Edits survive the confirm.
interface BuilderPlanAutomation {
name: string; // e.g. "Close won deals"
object: string; // NAME of the object the trigger watches
trigger: {
type: string; // "record_created" | "status_changed" | "field_changed"
field?: string; // status field (status_changed) / watched field (field_changed)
to_value?: string; // status_changed: the status value that fires the rule
};
action: {
type: string; // "update_status" | "update_field" | "create_record"
field?: string; // field to set (update_status: status field, update_field: field)
value?: string; // value to set (new status / field value)
target_object?: string; // create_record: NAME of the object to create
};
}
// One discovery question, carried on the inline `questions` gate. Same {value,label} option
// shape as the onboarding stepper, so the frontend reuses its button component. Server-owned
// — the client answers, never edits the question.
interface BuilderQuestion {
id: string; // stable id the answer references (q1, q2, …)
prompt: string; // the question text shown above the buttons
type: 'single' | 'multi' | 'text'; // single = radio, multi = checkboxes, text = free text
options?: BuilderQuestionOption[]; // choices for single/multi (omitted for text)
allow_other?: boolean; // allow a free-text "Other" beside the options
}
interface BuilderQuestionOption {
value: string; // what the answer carries
label: string; // what the user sees on the button
recommended?: boolean; // pre-highlight as the suggested pick
}
// The user's answer to one question, sent as `answers` on the next assistant:send.
interface BuilderQuestionAnswer {
question_id: string; // which question this answers
values: string[]; // picked option value(s): one (single), many (multi), [] (text)
text?: string; // free text — the whole answer (text), or the "Other" entry
}The share → publish → install lifecycle is REST on the object-template catalog — each
a direct link into the API Explorer:
GET /api/v1/object-template/pending— templates awaiting review (admin-gated)PATCH /api/v1/object-template/{slug}/publish— super-admin publishes a pending templatePOST /api/v1/object-template/{slug}/install— install a published template into the live workspaceGET /api/v1/object-template/suggestions— published-template suggestions
Frontend — what to implement
A step-by-step of what the client builds on top of the inline assistant:* contract
(INTEGRATION.md §5) + the payload types above. The plan is one preview
("simulation") the user reviews and edits before anything is written — schema, sample
data, relations, and automations all ride the same gate.
-
A build turn is a normal chat turn. You already have the
/ai-v2/chatsocket open. A build request comes back asassistant:agent { agent: 'workspace_builder' }, then a sequence ofassistant:confirmation_requiredgates. On reload, past plans/questions re-render from theblockon their history turn (GET …/messages). -
Interview, then plan. For a
questionsgate, render eachBuilderQuestionas buttons —single= radio,multi= checkboxes,text= a short input; showoptions(pre-highlightrecommended) plus a free-text "Other" whenallow_other. Reply with a normalassistant:send { answers: [{ question_id, values, text? }] }(an empty reply just moves on). Repeat until aplangate arrives. (A free-textassistant:sendwhile a plan is up is change feedback → the agent re-proposes.) -
Render the plan as one preview from the
plangate — four sections, all editable: schema (objects + theirfields), data (sample_rowsgrid per object, columns = field names), relations (source → target), and automations (rule cards, below). -
Automation rule cards. Turn each
BuilderPlanAutomationinto a human sentence — "When … → …" — using the object/field names it carries:Part Value Render as trigger record_created"When a {object} is created" trigger status_changed"When a {object}'s {field} becomes {to_value}" trigger field_changed"When a {object}'s {field} changes" action update_status/update_field"set {field} to {value}" action create_record"create a {target_object}" -
Edit / add / remove any card (schema, data, relations, automations) before confirming. Enforce these constraints client-side so nothing is dropped or fails at build:
Rule Constraint If violated automation needs identity nameandobjectrequiredsilently dropped on confirm (server sanitizer) status_changedtriggerneeds field+to_value(the status option value, not its label)survives confirm but the rule won't build / won't fire field_changedtriggerneeds fieldrule won't build update_status/update_fieldneed field+valuerule won't build create_recordneeds target_object; set anamefield/value so the record isn't blankrule won't build (or builds a blank record) references object/field/target_objectmust match a plan object/field by namerule builds but won't resolve at runtime caps ≤ 20 automations; action value≤ 500 charsextras/overflow trimmed Trigger types are exactly
record_created/status_changed/field_changed; action types exactlyupdate_status/update_field/create_record— render these as fixed pickers. -
Confirm. Send
assistant:confirm { approved: true, plan }with the full edited plan (objects, fields, relations,sample_rows,automations). Omitplanto build the reviewed plan as-is. Revise instead with a free-textassistant:send; decline withassistant:reject. -
Show build progress. Render each
assistant:tool_start/assistant:tool_resultas it arrives — flip the row to in-progress on start and tick it on result. The tool name is one ofcreate_object/create_field/create_relation/create_view/seed_sample_data/create_automation/create_folder. The build ends with thesharegate (below); a thrown build surfaces asassistant:error(terminal — stop the spinner, let the user retry). -
Offer to publish. The build-completion turn is a
sharegate (assistant:confirmation_required { type: 'share', plan }) with the build summary. Answerassistant:confirmto publish it as a pending template, orassistant:rejectto keep it private. -
Errors. Any
assistant:error { code, message }— handled exactly as for Units.
ERD
Graph topology, the human-in-the-loop gates, and the share/install lifecycle (the agent analogue of an entity-relationship diagram):
- Share:
WorkspaceTemplateSharingService.shareFromBuildsnapshots the built objects, validates, and saves withis_published:false(statuspending_review) + provenance (source: ai_build, sharer, workspace) into the main-DB catalog. - Publish:
ObjectTemplateRepositoryService.publish(slug)flipsis_published:true— only published templates are listed to users. - Install:
ObjectTemplateInstallService.install(slug, adminId)recreates the published template's schema into the caller's live workspace.