Corteksa

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/chat transport 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 behind assistant:confirmation_required gates, and on approval it builds the workspace, streaming assistant:tool_start / assistant:tool_result per step. There is no separate /ai-v2/builder socket and no builder:* 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:

GateEmitted asAnswered by
interview questionsassistant:confirmation_required { type: 'questions', questions }assistant:send { answers } (a fresh message)
plan reviewassistant: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_planplan gate → 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_plan near 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

  1. A build request on /ai-v2/chat routes to workspace_builder (the supervisor picks it, or a pending builder gate on the thread pins it). AiGraphService announces it with assistant:agent { agent: 'workspace_builder' }.
  2. WorkspaceBuilderAgentService runs the graph with the model bound only to ask_questions + propose_plan, so it can interview and plan but not write.
  3. Interview first: the model calls ask_questions; prepareQuestions commits the questions as a display block, then the questions node interrupt()s — surfaced as the questions gate. The client answers with assistant: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).
  4. The model calls propose_plan; prepareReview validates it and commits it as a display block, then the review node interrupt()s — surfaced as the plan gate. The gate carries the skeleton: objects and relations, no fields, rows or automations.
  5. (Optional loop) A free-text assistant:send while the plan is up → provideFeedback → resume { approved:false, feedback } → the model re-proposes → pauses again.
  6. assistant:confirm { plan? } (an edited plan is validated/clamped by sanitizePlan) → approve → resume { approved:true, plan }; the review gate flips the phase and the graph ENDS (no model write-loop).
  7. PlanEnrichmentService designs the approved skeleton's detail — each object's fields, then its example rows and the plan's automations — concurrently, reporting plan_fields / plan_examples steps onto the build's own progress channel.
  8. BuilderCompletionService builds 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 same GuardedToolExecutor (policy guard + idempotency), tracking each field's slug so automations resolve by slug. Every write emits assistant:tool_start when it starts and assistant:tool_result when it completes, so the client fills a live checklist — fast (seconds, no LLM round-trips). create_object is 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.
  9. The turn ends on a share gate: assistant:confirmation_required { type: 'share', plan } with the build summary. assistant:confirm snapshots the built objects and saves them as a pending catalog template; assistant:reject keeps it private. Either way the offer is consumed once.

Key components

  • Run facadeservices/workspace-builder-agent.service.ts — the inline agent: exposes run/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.
  • Graphgraph/builder.graph.ts — nodes agent / prepareReview / review / prepareQuestions / questions; the model is bound only to ask_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 in graph/propose-plan.tool.ts.
  • Deterministic buildservices/builder-completion.service.ts executes the approved plan directly (no model), driving the assistant:tool_start / assistant:tool_result stream.
  • Promptworkspace-builder.prompt.ts (interview-first, plan-first, FR-430).
  • Shareservices/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_rows and automations are deliberately NOT in its schema — the model volunteering them is dropped by toPlan, 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's sample_rows, so the preview grid is what actually lands.
  • create_automation — turns one plan automation into a real, active workflow (WorkflowService.createtoggle), so the rule the user reviewed runs. Exposes a self-contained subset — triggers record_created / status_changed / field_changed, actions update_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):

FieldTypeNotes
messagesBaseMessage[]append reducer; also carries the workspace_plan / builder_questions display blocks
approvedbooleanphase flag the review gate flips; true ⇒ the graph ENDS (build runs outside it)
questionRoundsnumberinterview 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

  • agentcallModel: binds [askTool, proposeTool] (or [proposeTool] past the interview cap) — never write tools; building happens after approval, outside the graph.
  • prepareReview / reviewprepareReview validates propose_plan, closes the tool call, and commits the plan as a display block (its own super-step); review reads that block, interrupt()s with a PlanReviewRequest, and resumes on a PlanReviewDecision { approved, plan?, feedback? }approval routes to END.
  • prepareQuestions / questions — the discovery counterpart: prepareQuestions commits the questions block, questions interrupt()s and folds the resumed answers into a human turn.
  • Facade & helpersWorkspaceBuilderAgentService, BuilderCompletionService, WorkspaceTemplateSharingService, BuilderShareStore.

Validators

  • proposePlanSchema validates the model's plan args; sanitizePlan validates/clamps a user-edited plan on confirm.
  • Write tools carry object.create / relation.create / workflow.create permissions (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:

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.

  1. A build turn is a normal chat turn. You already have the /ai-v2/chat socket open. A build request comes back as assistant:agent { agent: 'workspace_builder' }, then a sequence of assistant:confirmation_required gates. On reload, past plans/questions re-render from the block on their history turn (GET …/messages).

  2. Interview, then plan. For a questions gate, render each BuilderQuestion as buttons — single = radio, multi = checkboxes, text = a short input; show options (pre-highlight recommended) plus a free-text "Other" when allow_other. Reply with a normal assistant:send { answers: [{ question_id, values, text? }] } (an empty reply just moves on). Repeat until a plan gate arrives. (A free-text assistant:send while a plan is up is change feedback → the agent re-proposes.)

  3. Render the plan as one preview from the plan gate — four sections, all editable: schema (objects + their fields), data (sample_rows grid per object, columns = field names), relations (source → target), and automations (rule cards, below).

  4. Automation rule cards. Turn each BuilderPlanAutomation into a human sentence — "When … → …" — using the object/field names it carries:

    PartValueRender as
    triggerrecord_created"When a {object} is created"
    triggerstatus_changed"When a {object}'s {field} becomes {to_value}"
    triggerfield_changed"When a {object}'s {field} changes"
    actionupdate_status / update_field"set {field} to {value}"
    actioncreate_record"create a {target_object}"
  5. Edit / add / remove any card (schema, data, relations, automations) before confirming. Enforce these constraints client-side so nothing is dropped or fails at build:

    RuleConstraintIf violated
    automation needs identityname and object requiredsilently dropped on confirm (server sanitizer)
    status_changed triggerneeds field + to_value (the status option value, not its label)survives confirm but the rule won't build / won't fire
    field_changed triggerneeds fieldrule won't build
    update_status / update_fieldneed field + valuerule won't build
    create_recordneeds target_object; set a name field/value so the record isn't blankrule won't build (or builds a blank record)
    referencesobject / field / target_object must 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 exactly update_status / update_field / create_record — render these as fixed pickers.

  6. Confirm. Send assistant:confirm { approved: true, plan } with the full edited plan (objects, fields, relations, sample_rows, automations). Omit plan to build the reviewed plan as-is. Revise instead with a free-text assistant:send; decline with assistant:reject.

  7. Show build progress. Render each assistant:tool_start / assistant:tool_result as it arrives — flip the row to in-progress on start and tick it on result. The tool name is one of create_object / create_field / create_relation / create_view / seed_sample_data / create_automation / create_folder. The build ends with the share gate (below); a thrown build surfaces as assistant:error (terminal — stop the spinner, let the user retry).

  8. Offer to publish. The build-completion turn is a share gate (assistant:confirmation_required { type: 'share', plan }) with the build summary. Answer assistant:confirm to publish it as a pending template, or assistant:reject to keep it private.

  9. 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.shareFromBuild snapshots the built objects, validates, and saves with is_published:false (status pending_review) + provenance (source: ai_build, sharer, workspace) into the main-DB catalog.
  • Publish: ObjectTemplateRepositoryService.publish(slug) flips is_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.

On this page