-
Notifications
You must be signed in to change notification settings - Fork 2
reusable_vertical_product_platform
Status: In use. SparkQuill runs on the platform (hosted) and, today, on
its own family server (desktop). Decided 2026-09-06: the desktop becomes an
AgentWorks client too — desktop-sparkquill will spawn the platform's
workspace-server + agent_go server with SparkQuill as a product, and
cmd/family-server plus the standalone renderers in the learning app are
retired; cmd/family-server and internal/enginedetect were deleted later
the same day, so every cmd/family-server path below is historical (see the
2026-09-06 change-log entry and sparkquill_desktop_on_platform_plan.md). Dated notes at the end of this
document are the change log; the block below is the state as of 2026-09-03
and its "Decisions" paragraph is superseded on that point.
Built and on main. SparkQuill's parent and child conversations are
AgentWorks' ChatArea, hosted by PlatformChat and ChildPlatformChat;
streaming, tool chips, restore, scrolling and the turn layout are the same
code in every product, styled through tokens the product sets. Everything
product-specific is declared in product.yaml: tool bindings carry their
presentation (right panel) and interaction (in-chat rendering: pills,
celebration, scene) and the schedules block is the check-in. Platform-owned
tools live in internal/platformtools (first: platform.suggest-actions).
On the platform backend the app also covers setup (child profile, PIN),
handoff and continue-vs-fresh, secrets, voice status, the check-in popup,
pinned pages, the keys folder outside the child's sandbox, and a new-build
reload notice.
Decisions. The desktop app keeps its own family server; the standalone backend and renderers stay in the learning app and the two backends are not expected to have feature parity. WhatsApp stays unimplemented on the platform backend. The RTS deploy is not needed yet.
Open. Whether SparkQuill's Settings should list only its own secrets or the whole per-user store (today: the whole store).
Scope: AgentWorks (workflow engine), SparkQuill/family-server (first vertical), and 3-10 further dedicated products, each substantially custom. See "Designing for ten products, not two" — the target count changes the bar for every abstraction here.
Build a small shared backend and frontend platform, then build each dedicated product as a normal application on top of it.
Dedicated products must reuse operational infrastructure without being forced into one shared business model. A social-media product may have accounts, campaigns, discovery workers, and content calendars. A trading product may have market streams, positions, risk gates, and order approval. SparkQuill may have parents, children, learning activities, and educational reports. These concepts belong to their products, not to the platform.
Do not copy cmd/family-server to create social-server or trading-server.
The current family server is useful evidence for which infrastructure should be
extracted, but it should become a consumer of that infrastructure rather than a
template to duplicate.
Question asked: produce a plan to move SparkQuill onto the
product.yaml / agentprofiles.Profile format that Video Studio and Chief
of Staff now use.
Decision: do not migrate SparkQuill. Keep it a separate application. Let the next product be the one built against a platform boundary.
This does not contradict the architecture above — it applies it. This
document already says a dedicated product is "a full application, not a
data-only manifest", and records Configuration-only fit: no.
agentprofiles.Profile is precisely a data-only manifest, and is not the
platform this document proposes.
1. The child-safety boundary would regress. This alone decides it.
StrictAllowlist: true appears in exactly two places in the repo, both in
cmd/family-server/shell_tool.go (child :103, parent :187).
cmd/server/** never sets it, and never sets AllowNetwork. The child's
shell today is deny-by-default, scoped to one activity folder, with no
network and no secrets; the main server's shell is allow-by-default with
SECRET_* injected and unrestricted network. product_tool_gate.go cannot
close that gap — Admit(name string) bool sees a tool name, filters once
at registration, and is fail-open without mode: allowlist; its only lever
is removing execute_shell_command entirely, which is worse than a jailed
shell. agent_tools: mode: hybrid (Video Studio's setting) additionally
hands the model the CLI's own unsandboxed Bash/Read/Write, which the
gate never sees. Concretely: injected text from an uploaded worksheet today
lands in a sandbox with a one-folder blast radius; post-migration it would
land next to ~/.ssh and the open internet.
2. Risk is borne entirely by the mature product. SparkQuill is stable
and in daily real-family use. The benefit of a platform accrues to products
#3+, which would inherit it instead of copying cmd/family-server.
SparkQuill gains nothing it lacks today and absorbs all the regression risk.
3. Near-zero characterization coverage to migrate against. 12 test
files / 23 test functions, one skipped by default. Zero direct tests for
chat.go (772), whatsapp_bot.go (1,379), conversation_store.go (447),
parent_tools.go (405), child.go (347), shell_tool.go (209),
handoff.go, child_workspace.go, whatsapp_routing.go. Step 1 of the
migration sequence below remains unstarted, and it is a precondition.
4. The Video Studio precedent does not transfer. Video Studio was
absorbed successfully (d4efd631, 2026-08-08, "remove the standalone Video
Studio application", −11,072 lines; cmd/video-server + frontend/video-app
deleted after the product surface worked). But
video_studio_inside_agentworks.md step 8 states its standalone data was
"disposable development data and does not require migration" — the sentence
carrying the whole argument, and false for SparkQuill's real activity
history, attempts, memory files, and WhatsApp session. Video Studio was also
migrated while being built, not while stable: standalone backend ~3,700
lines vs family-server's 12,104. Only the frontends are comparable
(video-app 5,731 vs learning-app ~6,300–7,900), and Video Studio had no
Electron shell or native voice helper to consolidate.
5. This document's own criteria justify a separate app. "A separate app
is justified by a different user experience, trust boundary, permission
model, release lifecycle, or always-on service topology." SparkQuill has all
five: children vs. professionals; a child-safety trust boundary enforced by
an in-process filesystem sandbox; a PIN/no-auth model vs. three auth modes
and JWT; its own sparkquill-v* release cadence; and a server deliberately
kept alive when the window closes, for Pulse and WhatsApp.
6. It would define the boundary against one consumer — which the reuse rule forbids, and which "Open contradictions" below already flags.
Three of the largest blockers are single-tenancy in three costumes: global
mutable state (familyState.Child is a single pointer with no ID field;
currentActivityDir() takes no session parameter yet scopes the child
sandbox), process-global env collisions (MCP_API_URL/MCP_API_TOKEN are
os.Setenv'd by both cmd/server/server.go:1787-1789 and
internal/agentsession/agentsession.go:453,474-476; last writer wins), and
one-warm-CLI-session-per-process (agentsession.go:349-366). They share one
root: internal/agentsession configures MCP through os.Setenv. Until
that is fixed, "SparkQuill as a profile" means either a mutex serializing
every user in the server, or a race on process-global credentials.
SparkQuill already holds that global turn lock for minutes at a time
(chat.go:444-453 records an 8-minute hold and a 207s wait).
This is step 4 of the migration sequence below ("move generic
bridge/session/resume behavior out of the family-only internal/agentsession
adapter") — acknowledged there, still not done.
-
Set
agentsession.Config.Skills. family-server never sets it (verified: zero assignments in the package), and instead hand-copies embedded skills to disk each boot (skills.go:39-63) and instructs the model tocat skills/<name>/SKILL.md(chat.go:192-201). mcpagent already projectsSKILL.mdfor coding-CLI transports and exposesread_skillplus an "Available Skills" prompt listing. This deletesskills.goand gains progressive disclosure.skills/_shared/*.mdneeds a home either way —skillIDPatternrejects_shared. -
Fix the
reservedTopLevelfootgun.activity.go:59-72omits_users,Workflow,pulse, andmemories;archiveStaleActivities()os.Renames any non-reserved top-level dir idle for 7 days intoarchive/. A misconfiguredFAMILY_DATA_DIRwould silently relocate the main server'sWorkflow/andpulse/trees. Live today. -
Reconcile the folder-guard docs with the code.
docs/core/folder_guard_system.md:42states the_users/directory "(which contains authentication data, OAuth tokens, and session history) is strictly blocked from all read and write access."agent_go/cmd/server/tool_setup.go:556and:753both setprotectedFolders := []string{}with the comment "No protected folders — all users share the same filesystem", which makes theisPathProtectedchecks at:598and:660inert. A grep for an explicit_usersblock elsewhere in Go finds none; the only cross-user rejection found is inworkspace/handlers/query.go:43, which covers document/query access, not the shell folder guard. Whether any layer actually enforces the documented guarantee was not established — resolve it in one direction or the other, because a reviewer trusting this doc would approve an unsafe change. Independent of the SparkQuill question. - Add characterization tests for parent chat, child chat, handoff, activity isolation, streaming, and WhatsApp routing — valuable on their own merits for a product families use daily, and the precondition for any future extraction.
-
Do not consolidate voice yet. The main server's
pkg/voicesttlooks stronger on paper (per-connection streams, JWT auth, capability gating, andRuntimeCapabilities.Voicealready wired) versus family-server's Apple-Silicon-only stack with no auth on 12 endpoints and one speaker server-wide. ButPLAT-120isimplemented_pending_live_reverifywith no confirmed pass on real human speech, andvoicesttcannot decode audio containers, so WhatsApp voice notes have no path. Verify before acting. Note the frontend went the other way —frontend/src/voice/is an explicit port from learning-app, so two dictation implementations (356 vs 958 lines) are now diverging.
Trigger (a) below is now met: internal/agentsession no longer writes
MCP_API_URL / MCP_API_TOKEN / MCP_BRIDGE_API_URL / MCP_BRIDGE_BINARY
into the process environment. Its one shared executor is handed to every
agent as explicit mcpagent configuration (MCPRuntimeConfig.APIBaseURL,
APIToken, BridgeAPIBaseURL, CodingRuntimeConfig.BridgeBinary), and
mcpagent now prefers explicit values over the MCP_* variables, so a second
executor in the same process cannot clobber it. Remedy 5 (voice) is also
done: one engine (pkg/voicestt) serves both apps, including WhatsApp voice
notes. Remedies 1, 2 and 4 landed the same evening: the parent session now
carries the embedded skills through agentsession.Config.Skills (a live
codex-cli turn lists all thirteen; seedSkills still writes the files for
cat and for skills/_shared), reservedTopLevel now covers _users,
Workflow, pulse, memories, config, chat_history, Chats and
Downloads, and cmd/family-server/characterization_test.go pins parent
and child turns (prompt, tools, working dir, persistence), handoff,
the child shell's activity confinement through the real sandbox, PIN
handling, streaming, WhatsApp mode routing, and the reserved folders — all
driven through a session seam (turn_session.go) with no model. Remedy 3
(folder-guard docs vs code) is still open. One finding worth knowing: the
strict macOS profile leaves /var readable, so a workspace placed under
/var (as t.TempDir() is) is not confined; real installs under $HOME
are. Step 2's first slice landed the same night: pkg/whatsapptransport is the
one WhatsApp transport (session store, per-phone Account: connect, pair by
QR, send text/documents, react, download media; text extraction; dedupe).
family-server is its first consumer — its bot keeps only routing and media
policy (self-chat rule, inbox naming, @child/@parent) and no longer
imports whatsmeow's client. whatsmeow was upgraded at the same time (April →
August 2026 revision, which raised agent_go to Go 1.26): the old revision was
refused by WhatsApp as "client outdated", so pairing had been impossible for
both bots; verified live — a pairing attempt now yields a QR. Slice 2 (2026-09-03) then
made it one connector, not two: pkg/whatsappbot sits on the transport and
owns everything both bots did alike — session store, pairing and QR state,
reconnects, dedupe, the universal drop rules, @mention routing with a
per-chat memory of the active route, acknowledgement reactions, replies
with retry, self-chat sends — and calls the product through small
interfaces (Handler, AccessPolicy, Router + MentionMatcher,
RouteStore, CommandHandler, RouteObserver). SparkQuill is now a
two-row route table (@child/@parent, matched anywhere in the text,
persisted as the routing-mode file) plus its media and turn policy;
AgentWorks' WhatsAppService keeps the BotConnector adapter, the owner
and link-code policy, the workflow commands and the @slug route table, and
nothing of the protocol. Together the two files lost about 1,400 lines
(git diff --stat: 541 added, 1,368 removed). Behaviour differences worth
knowing: AgentWorks now de-duplicates redelivered messages like SparkQuill
did, and a pairing attempt's timeout now only covers the wait for the first
QR code (WhatsApp then paces the attempt itself), so the QR no longer dies
after 30 s while the settings page polls. Verified live for SparkQuill
(start, pairing QR, status); the AgentWorks side is covered by its service
tests and compiles, but was not exercised against a phone. Pulse followed on
2026-09-03 and turned out not to be "Pulse" at all: SparkQuill's check-in is
a product schedule (cron or cadence, a fixed list of messages sent one at a
time into the product conversation), which AgentWorks' Pulse review/fix
lifecycle is not. pkg/productschedule holds the definition, validation and
timing rule (cron with timezone, or cadence hours with a preferred hour, plus
a quiet rule) and a standalone runner with per-message status;
agentprofiles.Profile.Schedules lets a product.yaml declare them (singleton
conversations only); cmd/server/product_schedules.go runs them on the
AgentWorks server for every user with the product, with per-user enable
overrides and the same run-history file workflow schedules use, listed and
controlled through the existing /api/scheduler/jobs routes as
entity_type: "product". family-server's Pulse is now one such schedule on
the standalone runner (GET /api/pulse/status shows each check's state),
verified live with a manual run. The platform side has unit tests but was
not exercised against a real product turn. The decision itself stands; the
migration plan is at step 2 with only secrets remaining (recommended:
drop it).
Step 3 landed on 2026-09-03 as well: profiles: in product.yaml (one shared
loader, agentprofiles.LoadProductManifest, replacing the three per-product
copies), a per-profile prompt source, and runtime.sandbox (mode: strict,
network: disabled, read_only:) plumbed from the profile through the
session folder guard to the workspace shell isolator, which already had the
deny-by-default seatbelt SparkQuill's child shell used. Verified with a real
sandbox-exec run: a socket connect is refused by the kernel under
strict/no-network and reaches the port under strict/network. Nothing on the
SparkQuill side moved yet; step 4 writes its product.yaml against this.
Step 4's first slice followed the same night: internal/sparkquillproduct
holds SparkQuill's product.yaml (parent profile sparkquill: singleton
conversation at Chats/SparkQuill, strict sandbox with network, the
check-in as a declared schedule; child profile sparkquill-child: one keyed
conversation per activity under Chats/SparkQuill/activities, strict
sandbox without network, secrets disabled, allowlisted tools), both prompts
as files with {{.Product.*}} variables, and a runtime that computes those
variables per turn from the family's family.json (and memory/interests.md
for the child) through a new PromptVariablesProvider hook on the profile
registry. The family's thirteen skills moved into that package and the
standalone family server embeds them from there, so there is one copy.
Registered behind AGENT_PRODUCTS like every product. Verified on a
throwaway platform build: both profiles list with the right policies, the
check-in appears as a product schedule, and triggering it ran real turns
through the product conversation. What is still hand-wired in family-server
and not yet on the platform: the family tools (set_child_profile,
create_learning_activity, open_activity, suggest_actions, celebrate,
show_scene, send_whatsapp_file, the secrets tools), the learning-app surface,
the PIN handoff, WhatsApp routing, and the data migration. One finding: with
a single product enabled the server treats itself as a locked-down
deployment and requires CLAUDE_CODE_OAUTH_TOKEN, refusing the machine's
ambient Claude login; that is existing policy, not a SparkQuill issue.
The family tools followed as product tool factories
(internal/sparkquillproduct/tools.go): the three state setters write
family.json and the memory/ mirrors the skills read; create_learning_activity
writes activity.json plus a product.json that makes the activity folder
the child's own keyed conversation (activities are now flat under
activities/<slug>/, subject and topic live in the manifest); open_file and
open_activity are presentations (document.file, sparkquill.activity)
in the workspace's ui_presentations table, which the parent profile's
initializer creates; suggest_actions, celebrate and show_scene emit one
new generic event, product_interaction, that any product surface can
render; find_image uses the Wikimedia search now shared in
pkg/commonsimages with the family server. Secrets and notify_user were
already platform tools and are simply allowlisted. The parent profile moved
to project scope with a fixed root so the folder guard, the strict sandbox
and workspace secrets apply to it (global scope gets none of those). Not yet
ported: send_whatsapp_file (waits for the WhatsApp route table) and the
desktop notification channel. Verified with a real parent turn on the
throwaway build: "my daughter is Maya, grade 6, CBSE, call me mom" produced
set_child_profile and set_parent_label calls, family.json with exactly
that, two family_updated and one suggestions event in the session
stream, and a reply addressed to mom. The first attempt exposed a real bug:
the tools wrote to the profile's unexpanded workspace path and the folder
guard, keyed on the per-user path, denied it; the product now resolves the
per-user root the way Video Studio does.
Decisions taken the same day, which fix the shape of steps 3-5:
- SparkQuill stays a standalone app. The cutover target is a single-product desktop build of the platform, not a surface inside AgentWorks. Families never log into the shared server.
- The child is a PIN on the parent's account, as today, not a user of their own. The child profile is therefore a second profile of the same product under the parent's user folder, gated by the existing PIN handoff; the sharing model does not need to cover it.
-
The PIN handoff stays SparkQuill's own. The platform gets no generic
"switch profile behind a PIN" concept; the PIN hash lives in the family's
family.json, the verify step in the product's runtime, and the mode switch in the SparkQuill surface. - The RTS box is redeployed only at the end. It keeps its pre-migration build until the cutover; that deploy carries the shared WhatsApp connector, the whatsmeow upgrade and product schedules together.
Reopen this decision when either: (a) internal/agentsession no longer
configures MCP via process-global env, removing the single-tenancy root
cause; or (b) a second product is built against the platform boundary and
independently demonstrates the seams — at which point SparkQuill becomes a
candidate for adoption rather than the specimen the boundary is shaped
around. Absent either, a migration plan is premature regardless of how the
manifest format evolves.
This is not an anticipatory generalization. Two measurements make the case.
Much of the family server is infrastructure-shaped. agent_go/cmd/family-server is
11,020 non-test lines across 54 files. Classifying by filename — crude, but the
files are named for what they do and the split is not close:
infrastructure-named files 7,093 (21 files: whatsapp_bot, migrate, chat,
image_search_tool, pulse, conversation_store,
secrets_store, browser_*, shell_tool,
status_stream, steer, turntrace, voice_*, …)
family-domain-named files 1,928 (10 files: parent_tools, child*, week,
activity, learning_package_tool, materials, …)
remainder 1,999 (main/composition and unclassified)
This is an upper bound on extraction opportunity, not proof that all 7,000 lines
are generic. pulse.go, whatsapp_bot.go, and the voice files contain both
transport/runtime machinery and Family product behavior. Copying this server
would duplicate them, but extracting them correctly requires separating those
two responsibilities first.
A second implementation already exists. The reuse rule below says to extract
only after a second real consumer demonstrates common behavior. cmd/server
provides evidence of duplicate capability areas today, although the audit below
shows that each common primitive still has to be proven rather than assumed:
capability family-server cmd/server
whatsapp 1,445 3,143
pulse 521 3,127
secrets 444 702
browser 526 104
These measurements prove duplicated capability areas, not interchangeable semantics. For example, SparkQuill Pulse is a proactive learning check-in while AgentWorks Pulse is a finding/fix/verification system; SparkQuill voice is speech-to-text while the cited AgentWorks tools are generation. Extraction must find the common operational primitive beneath the same-name features rather than treating line counts as evidence that one implementation can replace the other.
The architecture can represent every current SparkQuill capability, but only because a product is allowed to retain a full native module. SparkQuill cannot be reduced to a content-only workflow/skill bundle, and the current AgentWorks Pulse, scheduler, frontend, and connectors cannot simply be plugged into it.
Measured current shape:
family-server backend 11,296 lines / 56 non-test Go files
LearningApp.tsx alone 4,837 lines
family-server HTTP routes 44 (all but one under /api)
family-server direct tests 1 (opt-in; see below)
learning-frontend direct tests 0
go test ./cmd/family-server now passes rather than reporting [no test files], but that is one opt-in integration test for the native voice path
(SPARKQUILL_VOICE_STREAM_TEST=1), skipped by default. It is not
characterization coverage of parent chat, child chat, handoff, activity
isolation, streaming, or WhatsApp routing — none of which any test exercises.
The SparkQuill release workflow builds the frontend, Go server, Swift voice
helper, and Electron package; it does not exercise their behavior.
Characterization tests remain a precondition for extraction, not a cleanup
task after it.
The audited ownership split is:
| Current SparkQuill capability | Correct target ownership |
|---|---|
mcpagent, provider selection, resume, steering |
Shared agent runtime |
| Browser executor, sandbox, diff patch | Shared platform; already partly reused |
| Parent/child roles, handoff, PIN boundary | Family product |
| Activities, materials, reports, teaching modes | Family product |
| Family teaching skills | Versioned Family experience assets |
| Proactive learning/site/memory check-ins called Pulse | Family automation plan on shared automation runtime |
| Finding/fix/verification Pulse | Optional shared quality-review service; not a replacement for Family check-ins |
| Pulse cadence and background execution | Shared execution scheduler |
| School/tuition/sports weekly calendar | Family domain data, not an execution schedule |
| WhatsApp pairing, session, media, delivery | Shared connector transport |
@parent/@child, self-chat, activity ingestion |
Family routing and policy |
| Desktop/WhatsApp notification mechanics | Shared delivery interfaces |
| Hugging Face backup target and Family summary policy | Family adapters and policy |
| Voice transcription (native Swift/CoreML; MLX/Python for WhatsApp notes) | Family native capability initially |
| Conversation/event transport | Shared only after a normalized product-facing contract exists |
| SparkQuill navigation, activity viewer, academic map | Fully custom Family frontend |
Four boundaries are load-bearing:
- Pulse is two services, not one. A proactive automation service runs scheduled domain check-ins and writes results into the product experience. A quality-review service owns findings, fixes, verification, and recurrence. A product may use either or both.
- Scheduling executes work; it does not own domain calendars. The platform owns triggers, locking, retries, recovery, and run history. Family owns a child's weekly commitments; Social owns a content calendar; Trading owns market-session rules.
- Connector transport is shared; conversation meaning is not. Pairing, inbound normalization, attachments, delivery, retries, and receipts belong to the platform. Product routing, permissions, and side effects stay local.
-
Frontend reuse starts below the visual product. SparkQuill currently uses
a small
status|delta|tool_callSSE shape while AgentWorks uses a much larger generated event model. Share a normalized protocol, API client, state machines, and React hooks first. Keep the SparkQuill UI fully custom.
This yields a precise confidence statement:
- Functional fit: yes; no current SparkQuill feature falls outside the platform-plus-native-product model.
- Configuration-only fit: no; voice, activity isolation, Family routing, domain storage, and custom UI require native product code.
- Reuse without refactoring: no; several current implementations have incompatible contracts or mixed platform and product responsibilities.
- Safe incremental migration: yes, after behavior is pinned with tests and each shared seam is extracted independently.
mcpagent
agent execution, tools, skills, sessions
|
Shared AgentWorks Platform
backend runtime + operations + connectors + frontend runtime
|
+---------------------+---------------------+
| | |
Family product Social product Trading product
own domain/API own domain/API own domain/API
own data and UI own data and UI own data and UI
The dependency direction is strict:
products -> platform -> mcpagent
- The platform never imports a product.
- Products never import one another.
-
mcpagentremains product- and transport-agnostic. - A product may use all shared services, only some of them, or provide a product-specific adapter where its requirements genuinely differ.
The shared runtime owns:
-
mcpagentconstruction and lifecycle; - model-provider configuration;
- tools and skills;
- sessions, continuation handles, resume, and steering;
- streaming and structured events;
- background-agent execution;
- tool-error normalization;
- output truncation and full-output artifact retention;
- shared MCP bridge lifecycle.
Products provide prompts, tool implementations, skill bundles, permission intent, and domain-specific agent roles. They do not rebuild session or bridge machinery.
The platform exposes two composable services rather than one universal Pulse.
The proactive automation service owns:
- scheduled and manual domain check-in execution;
- locking, deferral, cancellation, and recovery;
- ordered check plans and per-check status;
- delivery of results into the product's conversation or dashboard;
- invocation of the shared post-run pipeline when configured.
SparkQuill's current Pulse is a Family-owned plan on this service: review learning activity, check saved sites, update preferences/interests, back up, and send a parent summary in the single parent conversation.
The quality-review service owns:
- review module lifecycle;
- findings, deduplication, status transitions, fixes, and verification;
- human decisions and approval states;
- backlog and recurrence handling;
- review and fix audit history;
- dashboard projections;
- final-command state and recovery.
Products may contribute domain evidence and quality modules. For example:
- Social may contribute account health, audience strategy, engagement quality, and platform-policy reviews.
- Trading may contribute data freshness, execution quality, exposure, risk, and strategy-drift reviews.
- Family may contribute learning progress, content quality, safety, and parent follow-through reviews.
The finding lifecycle and UI remain shared when a product opts into quality review, even though the meaning of a finding is product-specific. A proactive check-in does not have to manufacture findings merely to use the automation runtime.
The shared scheduler owns:
- cron and manual triggers;
- next-run calculation;
- concurrency and locking;
- retries and timeout handling;
- missed-run reconciliation;
- run status and history;
- asynchronous worker lifecycle.
Products register jobs and their business behavior. They do not implement another scheduling engine.
This service schedules execution only. Product calendars remain product data: SparkQuill's school/tuition/sports week, a Social content calendar, and Trading market-session rules are not platform scheduler records unless they actually trigger executable work.
WhatsApp, Slack, email, and future channels are shared transports. The platform owns:
- authentication and connection state;
- inbound message and attachment normalization;
- outbound delivery;
- retries, rate limits, and delivery receipts;
- channel health and diagnostics;
- secret-safe credential handling.
Products own message interpretation and routing. A WhatsApp message may be a parent request in Family, a campaign approval in Social, or a risk alert in Trading; that meaning must not leak into the connector package.
Backup, publish, and notify form a reusable post-run pipeline:
work completed -> backup -> publish -> notify
This pipeline must not be inseparably coupled to Pulse. Pulse, a scheduled job, or a manual operation may invoke it. The platform owns ordering, status, recovery, and truthful partial failure. Products provide policy and adapters:
- which artifacts are backed up;
- where they are published;
- who receives a notification;
- which actions require approval.
The platform should also own:
- browser/CDP execution;
- shell sandboxing and folder guards;
- file and artifact handling;
- secrets and credential injection;
- authentication and authorization primitives;
- storage and migration utilities;
- event logging and observability;
- costs, tokens, and usage accounting;
- approvals and durable human input;
- health checks and lifecycle management.
The frontend platform owns headless execution state and optional reusable UI primitives. It does not mandate one application shell, navigation model, design system, or event-detail density for every product. Shared areas include:
- chat messages and composers;
- streaming assistant text;
- tool-call arguments, results, and failures;
- background-agent and workflow-run status;
- steering and cancellation;
- uploads and attachments;
- Pulse findings, reviews, fixes, and verification;
- schedules and run history;
- connector settings and delivery state;
- human-decision cards;
- shared navigation, typography, colors, dialogs, and accessibility behavior.
The shared event contract should include at least:
message_started
message_delta
message_completed
message_failed
tool_started
tool_completed
tool_failed
status_changed
human_input_required
run_completed
run_failed
Products may introduce domain events, but they should render through explicit product-owned components rather than changing the meaning of core events.
The listed contract is a target normalization layer, not the current wire
format. SparkQuill's status|delta|tool_call SSE stream and AgentWorks' generated
event inventory need adapters into this contract before components are shared.
The first extraction should therefore be API clients, reducers/state machines,
and React hooks; visual components are optional consumers.
In the current AgentWorks frontend, ProductChatSurface is the canonical
consumer of that normalized conversation contract. ChatArea installs it
automatically whenever a product selects inputVariant="product". Its adapter
maps the existing agent_error, conversation_error, failed completion, and
cancel events into one message_failed state with a stable code, safe user
copy, retryability, optional retry time, and collapsed technical details. A
product may replace the visual renderer, but it must consume this shared state
rather than parsing provider strings or inventing a product-local error event.
Suggested frontend structure:
frontend/packages/platform-api
frontend/packages/design-system
frontend/packages/chat-runtime
frontend/packages/tool-events
frontend/packages/pulse-ui
frontend/packages/schedules-ui
frontend/packages/connectors-ui
frontend/products/family
frontend/products/social
frontend/products/trading
Use compile-time composition initially. Runtime-loaded frontend plugins would add deployment, compatibility, and debugging complexity before there is a demonstrated need for them.
A dedicated product is a full application, not a data-only manifest. It owns:
- its domain model and rules;
- HTTP endpoints and commands;
- database schema and repositories;
- workers and external integrations;
- prompts, tools, skills, and agents;
- product-specific Pulse modules;
- product-specific screens and components;
- security and approval policy beyond the shared minimum.
A small composition contract is sufficient:
type Services struct {
Agents agent.Factory
Sandbox sandbox.Factory
Browser browser.Service
Secrets secrets.Store
Events events.Bus
Scheduler scheduler.Service
Automation automation.Service
QualityReview qualityreview.Service
Notifications notifications.Service
Storage storage.Factory
}
type Application struct {
HTTP http.Handler
Workers []Worker
Close func(context.Context) error
}
func Build(ctx context.Context, services Services) (*Application, error)This is a composition boundary, not a requirement that every product expose the same features. A product can ignore services it does not need. Domain-specific APIs stay inside the product.
Avoid a large plugin interface with methods such as RegisterTools,
RegisterSkills, RegisterRoutes, RegisterJobs, and dozens more. That would
recreate the public-API problem recently removed from mcpagent. Prefer an
immutable service bundle passed to one product constructor.
The platform owns common operational records:
- runs and sessions;
- schedules;
- costs and usage;
- Pulse reviews, findings, fix attempts, and verification;
- decisions, approvals, and notifications;
- connector and delivery status.
Products own domain records:
-
social_*for campaigns, accounts, posts, audiences, and attribution; -
trading_*for instruments, market observations, positions, orders, and risk decisions; -
family_*for profiles, activities, materials, progress, and reports.
Each product supplies versioned migrations for its own schema. Agents should use registered query and mutation tools instead of unrestricted direct SQLite shell access. Shared operational tables must not accumulate product-specific columns.
The same architecture supports two deployments:
AgentWorks composes all enabled products into one control plane. This is best when one operator manages several kinds of agent work.
A dedicated executable composes the shared platform with one product:
func main() {
host := platform.New(config)
app, err := social.Build(context.Background(), host.Services())
if err != nil {
log.Fatal(err)
}
host.Run(app)
}The executable should remain thin. A separate app is justified by a different user experience, trust boundary, permission model, release lifecycle, or always-on service topology—not merely by different prompts or workflows.
Share infrastructure; keep business meaning local.
| Shared platform | Product-owned |
|---|---|
| Deliver a WhatsApp message | Decide what the message means |
| Run and resume an agent | Define the agent's domain job |
| Schedule and recover a job | Define what the job does |
| Record a Pulse finding | Decide which evidence is a domain problem |
| Execute a browser tool safely | Define the permitted business action |
| Store and inject a secret | Define which credential a product requires |
| Stream a tool result | Render a product-specific result card when needed |
| Back up and notify | Choose artifacts, destination, audience, and policy |
Extract a shared abstraction after a second real consumer demonstrates the common behavior. Do not generalize a Family-only, Social-only, or Trading-only concept in anticipation of reuse.
For the capability areas named in Evidence, cmd/server and cmd/family-server
justify investigating extraction. A service enters the platform only after its
common contract is demonstrated; similar names and line counts are not enough.
For anything else, wait for the second consumer.
Three rules in this document are currently stated as prose. Prose holds until the first deadline. Each needs a mechanism that fails loudly, because all three share a failure mode: nothing errors, the system just quietly stops being what the document says it is.
The event contract must be pinned, not listed. Products ship on separate
release lifecycles, so a platform that renames or repurposes tool_failed finds
out from a user, not a test. Pin the exact event-name inventory with an AST or
schema golden test — the same ratchet used for the mcpagent public surface,
which pins sorted names rather than a count so a deleted event cannot be
silently replaced by a different one. This project has already paid for the
alternative: docs/refactor/lazy_per_terminal_event_loading.md documents a Go
list and a TypeScript list that must agree, held together by two comments, where
a drifted copy does not error — it silently drops events from a transcript.
"Shared operational tables must not accumulate product-specific columns" needs a schema assertion. A migration-time check that rejects unknown columns on platform-owned tables converts the rule from a convention into a property. As written it is enforced only by review attention.
The conformance suite needs an owner and an entry point. "Every product must run the same platform conformance suite" is a wish unless the suite is an importable package a product's CI executes, failing the product build when a contract regresses. Name the package. Given that unit tests over agent behavior count for little here, at least the bridge, streaming, and tool-failure cases should run against a real coding agent rather than a mock.
Services correctly answers the mcpagent lesson: an immutable bundle passed
once, not a mutable registration surface invoked in an order the caller must get
right. The failure mode it remains exposed to is growth — nine fields become
twenty, and every product carries services it never uses. Add an admission rule:
a service earns a slot when two products need it, and is removed when one does.
That is the same reuse rule applied to the composition boundary itself.
The runtime section lists "tool-error normalization." Name the contract
explicitly, because its entire value is that one command works everywhere:
[TOOL_ERROR] for reported failures, [TOOL_ERROR_SUSPECT] for reported
successes whose payload reads like a failure, both carrying layer=, tool,
session, args, and result, so grep '\[TOOL_ERROR' covers every product,
provider, and transport. If products are allowed to invent their own error
logging, cross-product operability is lost on the first one that does.
Every product must run the same platform conformance suite:
- agent construction and prompt/skill visibility;
- MCP bridge tool discovery and invocation;
- streaming, steering, completion, and resume;
- normalized message failure, retry, and safe technical-detail rendering;
- tool-success and tool-failure rendering;
- large-output truncation plus full-artifact retention;
- folder-guard and secret boundaries;
- schedule execution and recovery;
- proactive automation execution, deferral, and recovery;
- quality-review finding/fix/verification lifecycle for products that enable it;
- backup/publish/notify partial-failure behavior;
- frontend event compatibility.
Products add domain tests for their own behavior. At least one real coding-agent E2E should exercise the same bridge and event path used in production.
- Add characterization tests for parent chat, child chat, handoff, activity isolation, streaming, WhatsApp routing, proactive Pulse, and packaging.
- Freeze the first contracts being extracted for the duration of each slice.
- Extract shared shell execution and large-output handling.
- Move generic bridge/session/resume behavior out of the family-only
internal/agentsessionadapter and into the appropriate shared runtime. - Replace the family browser HTTP shim with a reusable in-process browser execution adapter.
- Extract connector transport while retaining Family routing and media policy.
- Extract the execution scheduler and post-run pipeline; keep Family calendars and backup targets product-owned.
- Separate proactive automation from quality review rather than replacing SparkQuill Pulse with AgentWorks Pulse.
- Normalize conversation events, then extract headless frontend clients, reducers, and hooks before any visual components.
- Convert Family into the first product using the platform boundary while preserving its custom frontend and native voice/activity capabilities.
- Build the second product against the platform boundary and adjust only abstractions proven insufficient by that real implementation.
- Build Trading last, after the platform has survived two distinct products.
The second consumer defines the boundary, so it should be the one that stresses
it honestly at the lowest cost of being wrong. Voice is not that second
product. SparkQuill's voice code is on-device speech-to-text — as of
2026-08-02 a native Swift/CoreML helper for live dictation, with the MLX/Python
worker retained only for WhatsApp voice notes (see
docs/refactor/native_streaming_stt.md) — while the cited AgentWorks
audio/music tools generate media. They share artifact/process
primitives but not one product capability, so adding their line counts would
repeat the same-name/same-semantics mistake this audit found in Pulse.
Use a real second end-user product. Social is the leading candidate because existing social workflows and data provide concrete behavior to migrate, while its actions can begin read-only or approval-gated. The choice should be made after the first platform slice is defined, using the product that exercises the most uncertain seam without introducing irreversible risk.
Trading should be last, and the reason is not sequencing convenience. A grep
for trading concepts across agent_go returns nothing — it is greenfield, so it
supplies no duplication evidence and cannot demonstrate which abstraction is
genuinely shared. More importantly, its failure modes are categorically different
from the other products: real money, latency budgets, regulatory retention, and
irreversible actions. A platform boundary discovered under those constraints is
discovered expensively. Let Family and Social prove the seams first,
then let Trading exercise the approval, audit, and permission boundaries it
actually needs — which is precisely the case this document already makes for when
a dedicated application is justified.
This sequence avoids designing a speculative plugin framework while preventing new products from copying the current family-server infrastructure.
The twelve steps above are written primarily around cmd/family-server,
which is the smallest of the three masses involved:
frontend/src 141,664 lines
agent_go/cmd/server 89,241 lines
agent_go/cmd/family-server 11,296 lines
All three counts exclude test files; an earlier revision compared a tests-included frontend number (147,006) against a tests-excluded server number, which is not a like-for-like ratio. Those steps begin with roughly 5% of the code this architecture ultimately touches. Two questions are load-bearing and currently unanswered:
What becomes of cmd/server? At 89,241 lines it holds the Pulse, scheduler,
and workflow machinery this document proposes to share, so it is simultaneously
the largest source of platform code and the largest product. "AgentWorks composes
all enabled products" implies it splits into platform plus a workflow product,
but no step describes that split. Converting family-server first is the easy
direction; it proves the boundary on the smaller consumer without proving it can
carry the bigger one.
Frontend extraction needs its own implementation plan. Step 9 deliberately starts with event normalization and headless clients/reducers/hooks, but that is still only the boundary for a package split of the largest mass in the repository. Do not interpret it as authorization for a broad component move. Inventory consumers, pin the normalized contract, extract one state machine at a time, and keep both visual applications unchanged until each slice passes its conformance tests.
Neither question changes the architecture, which the evidence supports. They change the estimate. This is a multi-quarter program, not a restructuring pass, and the plan should say so before anyone commits to a date.
The target is moving. Both servers are under active development —
folder-guard normalization, tool-error instrumentation, event ownership, and the
mcpagent public surface all changed within a single day in August 2026.
Extracting shared infrastructure from code that is still changing means the
extraction rebases continuously. Either freeze the interfaces being extracted for
the duration of each step, or accept that steps 1–4 will be redone. Naming which
is the point; discovering it mid-migration is not.
Stated goal (2026-08-02): AgentWorks remains the workflow engine; SparkQuill is the first vertical; the intent is 3-10 more like it. Every one of them will be genuinely custom, with substantial unique features of its own — none is a skin over a shared product.
That target changes what "success" means here. The Evidence section above justifies extraction from a second consumer. A tenth consumer is a different bar: an abstraction that is merely tolerable is paid for nine more times.
Products being large is not the problem. Products being large because they rebuilt the plumbing is. SparkQuill's backend, classified by filename (2026-08-02, crude but directional):
mechanism that belongs to a platform 7,260 64%
genuinely Family domain 1,633 14%
main / composition / other 2,403 21%
total 11,296
A rich, fully custom product turns out to contain roughly 1,633 lines of genuinely unique backend. Everything else is WhatsApp, Pulse plumbing, secrets, browser, shell, streaming, steering, and voice — rebuilt because there was no platform to inherit them from.
So ten custom products should not cost ten times SparkQuill:
wrong 10 x 17,500 lines (server + frontend, each product standalone)
right platform once + 10 x (~2,000 domain lines + its own screens)
This is the number to hold the migration to. If a new product approaches SparkQuill's current size, the platform boundary is in the wrong place. Track the domain share per product; it should rise toward 100% of what a product team actually writes, not sit at 14%.
Nothing here argues for thinner or more uniform products. It argues that "custom" should mean its own domain, not its own copy of the mechanism.
LearningApp.tsx is 4,837 lines of a 6,210-line product frontend — 78% in one
file. That file is not 4,837 lines of unique teaching behavior: it interleaves
streaming, the composer, mic capture, tool cards, SSE subscriptions, scroll
management, and file trees with Family-specific UI. It is the mechanism/meaning
split violated at file level, which is why unrelated changes keep landing in it.
Step 9's ordering (normalize events, then extract clients, reducers, and hooks before visual components) is right, and "keep the SparkQuill UI fully custom" is right for SparkQuill. Neither is sufficient at ten products, because it leaves each new product writing chat mechanics again. The platform additionally needs a composable application shell — chat surface, composer, tool-result rendering, run status — so a product's UI is hundreds of lines of arrangement and its own screens, with personalization living in theme, layout, copy, and domain components rather than in a re-implemented chat surface.
The document covers composition (Deployment modes) but not distribution. At
ten products that gap is larger than it looks. Two products today already carry
duplicated shipping surface:
.github/workflows/desktop-release.yml .github/workflows/sparkquill-desktop.yml
install.sh install-sparkquill.sh
desktop/ desktop-sparkquill/
tag namespace v* tag namespace sparkquill-v*
This is not hypothetical risk. On 2026-08-02, publishing the first real
SparkQuill releases silently broke AgentWorks' updater in production: GitHub's
/releases/latest returns whichever app shipped most recently regardless of
which app is asking, so AgentWorks began reading a sparkquill-v* tag, parsing
it to a garbage version, concluding "not newer", and never reporting its own
updates again. install.sh had the same fault, where a fresh install would
chase a dmg that does not exist. Both failed silently; neither errored.
Two products produced that with one shared endpoint. Ten products have forty-five pairs to collide in. Release pipeline, installer, updater, tag discipline, signing, and icon/branding pipeline should be one parameterized platform capability with a per-product manifest — not per-product shell scripts maintained by copy.
The reuse rule ("extract after a second real consumer") and the ten-product goal pull in opposite directions, and the tension should be named rather than averaged away. Two consumers prove an abstraction is possible; they do not prove it is right for the eight after them. The practical resolution is to keep the rule, but choose the second consumer for how differently it stresses each seam — and to treat the first two products as still-provisional, budgeting one deliberate revision of the boundary after product three rather than discovering the need for it at product six. See also "Open contradictions" below, which records the sharper problem that the current step order reaches step 10 with only one consumer.
Two places where the plan argues against itself. Both matter because the point of this document is to make the second product cheap, not to tidy the first.
The migration defines the boundary with one consumer, which the reuse rule forbids. The rule is explicit: extract a shared abstraction after a second real consumer demonstrates the common behavior. But step 10 converts Family onto the platform boundary and step 11 builds the second product after it. A boundary drawn against Family alone will be Family-shaped, and the second product pays for that — exactly the outcome this document exists to prevent. "Scope realism" half-concedes this ("proves the boundary on the smaller consumer without proving it can carry the bigger one") without resolving it. Either state an explicit first-consumer exemption and accept one rewrite after product two, or interleave steps 10 and 11 so the first shared seam is proven against both consumers before it is called a platform.
Characterization tests are scheduled before the freeze that makes them stable. Step 1 writes characterization tests; step 2 freezes the contracts being extracted. "The target is moving" then warns that steps 1-4 will otherwise be redone. Tests written against a contract that is still changing are the first thing invalidated, so the freeze belongs before the tests, per slice, not after.
- One universal domain model for every product.
- Runtime loading of arbitrary Go plugins.
- Making every product use every platform feature.
- Putting product-specific rules into
mcpagent. - Sharing code merely because two functions currently look similar.
- Copying a dedicated server and allowing the copies to drift.
-
Whole-turn text. Coding-agent providers commit only the last assistant
message as
final_result(workflow semantics). The claude-code adapters now also emitassistant_turn_text(every assistant text block of the turn, in order) in the generation metadata; the agent server persists it onllm_generation_end. Chat surfaces show that;final_resultis unchanged. -
Restore from the persisted chat history. The live event store is in
memory, so a product app must rebuild history from
GET /api/chat-history/sessions/{id}. The converter that AgentWorks used for this (conversationToRestoredEvents) moved tofrontend/shared/session/restore.ts; SparkQuill's history goes through it and then through the same event→message mapping as a live turn. -
Streaming chunks. The chunk rules (
source: terminalnever prose, tool markers → status, chunk 0/1 restart, delta vs block join) live infrontend/shared/session/streamingStatus.tsfor every surface. - Open: suggestion pills and product cards emitted as
product_interactionare not in the persisted history, so they do not survive a reload; product schedule turns restore as plain user bubbles (nosource: pulsemarker).
Feedback: activities came out as tests, and the design guide (2,500 words) was being skimmed and ignored. Decisions (user):
- No activity skill. Quill decides sequence, depth and style from the request and the child's evidence; the prompt gives only the mechanics.
- No imposed look. The page is Quill's own HTML (fragment or whole document,
own styles, animations, demos).
create_learning_activityfinishes a<name>.sq.htmlinto<name>.html(agent_go/internal/sparkquillproduct/page.go): ids on sections/questions/figures, an answer space per question, the print hook andSQ.choosescript, and it removes only form controls,<details>, links and remote resources. It reports what it dropped. - The prompt tells Quill two things only (user: "just how to use SQ"):
<div class="q">around each question so the tutor can scroll her to it (open_file focus) and note her answer there, and<button data-choose>for a choice she taps. Section roles are accepted by the finisher when present but not asked for; the child prompt is goal-steered as before. - create-study-material and create-test skills deleted, activity-page.md too; html-design.md remains for the two reports only.
- Viewer: the injected page script now pads the body bottom so the last element never sits flush against the frame. Verified live: "explain, practice, short check" on fractions produced a real guide (hook, steps, worked examples with CSS fraction bars, hint buttons, 4-question check) in 13 tool calls.
-
Pinned pages. Any HTML page can be a tab at the top of the parent's
screen:
pin_page(path, title)/unpin_page(path)tools write the app's own per-key state file (state/pins.json, keypins), the same one the app's Pin/Unpin buttons write (file viewer for any .html, Unpin on the tab), so both sides see one list.pins_updatedproduct event refreshes the app. -
One progress page. Academic map + progress report merged into
reports/progress.html(what she has, how she is doing, what next, how the parent can help); one tab, one skill, one menu entry. -
Guides are a skill.
skills/_sharedwas invisible toread_skill(not a skill), which cost every page-building turn two or three failed lookups. It isskills/guidesnow (SKILL.md + html-design.md + diagrams.md), attached to both profiles. -
Chat UI reuse, current state. AgentWorks and Video Studio render the
transcript with
components/ChatArea+TerminalEventTranscript; the learning app renders withfrontend/shared/chat(ChatRenderer, ToolCallSummary), used by nothing else. Same events, different renderer. Moving SparkQuill onto ChatArea the way Video Studio did is the next consistency step and is not started.
User: "I want to have the same [chat UI] so we can fix and debug the same
issues." In platform mode the learning app now hosts components/ChatArea
exactly as Video Studio does (frontend/learning-app/src/platform/PlatformChat.tsx):
tab priming (mode stores, resolveAgentProfileConversation, createChatTab,
restoreSession, hydrateTabEvents), the product composer, SSE/polling,
streaming, submission and TerminalEventTranscript rendering are the shared
code. SparkQuill adds a content renderer (transcript + suggestion pills via
the new onSubmitQuery renderer prop), product commands as slash commands,
and a selector over useChatStore.tabEvents that hands product_interaction
and presentation_updated events to the workspace panel.
Host requirements learned the hard way:
- The AgentWorks service layer snapshots its base URL when its modules are
evaluated; a dependency-free
runtimeConfig.tsimported first inmain.tsxsetswindow.__APP_RUNTIME_CONFIG__before that happens. (Symptom otherwise: provider-manifest fetch hits the preview origin, gets the SPA's HTML, the store heldproviderManifest: undefined, and the composer crashed on.find. The store now guards that too.) - Login token mirrored into
auth_token(the shared services' key), also when an existing token is reused. -
lucide-reactaliased to AgentWorks' copy by FILE (a directory alias bypasses the packageexportsmap);zustand/react deduped. - Tailwind pipeline with AgentWorks' theme and preflight OFF;
index.cssimported for the shadcn variables;html.light|darkkept in step. - Open the tab once per page load (module-level promise); re-running the Video Studio sequence on a remount re-hydrates from history and discards the live turn.
- Shared components were dark-only;
TerminalEventTranscripttext now carriesdark:variants so it reads on a light surface. Standalone mode keeps the old renderer until the family-server is retired. Child mode (keyed activity conversations) is not moved yet.
-
Styling. The shared composer and transcript paint with the shadcn tokens
only; a product sets the token values on its wrapper (SparkQuill:
.fl-platform-chatin learning-app.css, light + dark). A host without Tailwind preflight gives the hosted chat a:where(button…)baseline. -
Suggestion pills are a platform feature. Contract in
frontend/shared/session/interactions.ts(product_interactionkindsuggestions, payload{actions:[{label,message}]}), selectorsrc/platform/interactions/useProductInteractions.ts(twin ofusePresentationEvents), componentsrc/platform/chat/ProductSuggestions.tsx. A product opts in withui_panels.suggestions: true; the surface reads the profile (agentApi.getAgentProfile) instead of hardcoding it. Thesuggest_actionstool itself still lives in the SparkQuill package; moving it to a platform factory is the remaining step. -
Presentations. The surface derives the kinds it reacts to from the
profile's tool bindings (
tools[].presentation.kind) and uses the platform'susePresentationEvents. - Product tool events are stamped with a real timestamp at emission
(
stampEventData): a zero inner timestamp sorted them to year 0001 in a restored trace, which is why pills vanished after a reload. - Restore keeps only the last
maxPersistedChatHistoryUIEvents(200) trace events, so tool chips of older turns do not survive a reload in any product; the durable conversation has the tool messages, so the converter could synthesize chips from them (not done).
A tool that renders something in the chat now says so on its own binding in
product.yaml, next to presentation::
- id: sparkquill.suggest-actions
interaction:
kind: suggestions # the product_interaction kind the tool emits
render: chat.suggestions # the shared chat rendering for itToolBinding.Interaction reaches the factory as
ToolRuntimeContext.Interaction (same path as Presentation), so the factory
reads the kind from the binding instead of hard-coding it. The host reads
tools[].interaction.render === 'chat.suggestions' from the profile and mounts
ProductSuggestions with that kind. ui_panels.suggestions is gone: the
chat renders whatever the tools declare, so a product adds a pill-emitting tool
and gets pills without touching the frontend.
Scrolling in the SparkQuill parent chat uses the platform's follow-turn
mode (the same one AgentWorks uses), which follows the whole turn until the
reader scrolls up, instead of the reveal-first-response variant.
-
persistChatConversationToPathWithTerminalSessionused to write the in-memory event store asui_events, so after a restart the first turn replaced the whole saved trace with itself. Restore then interpolated every older message's timestamp across that one turn and the newest reply sorted above turns from ten minutes earlier. Persist now merges the saved trace (events older than the live store, deduped by id) before the live events and trims to the cap; restore anchors interpolation on the first user prompt the trace still holds, so untraced turns stay before it (restore.ts). - In the transcript, a turn's
AGENT · duration · timeheader is drawn on the tool batch that begins the turn, and the reply below it omits its own header, so tool work reads as part of the reply. Expanded tool cards use card/muted tokens (light on light products) instead of black overlays. -
initialTopMostItemIndexis pinned at mount; a late first fill scrolls to the end explicitly. Note for anyone verifying in Chrome: a background tab never fires ResizeObserver, so Virtuoso renders nothing there. Check in a foreground tab.
The handoff is client-side workspace state, like it was on the family server:
FamilyWorkspace.handoff(dir, resume?) checks the activity's manifest, moves
current-activity.json to it and answers {dir, title, goal, new_session}.
new_session is !resume when the app asked explicitly, otherwise "a
different activity than the current one". The app then enters child mode and
kicks off the child conversation (conversation key = activity slug) exactly
as before. Until now platformApi.handoff was a notYet stub, so the button
rejected silently on the platform backend.
ChildPlatformChat opens AgentWorks' ChatArea on the child profile's
conversation for one activity (conversation key = activity slug, one tab per
activity per page load). The child's own pieces sit around the shared
transcript, the way the parent's pills do:
- Tool bindings declare their in-chat rendering:
celebrate→chat.celebration,show_scene→chat.scene. The transcript gainedproductRows— product interactions of the declared kinds are kept as rows inside the agent's turn and rendered by the product (star row, sandboxed scene iframe); every other interaction kind stays on the side channel. - The handoff kickoff is an ordinary message in the child's voice and shows
like any other (decision 2026-09-03: no hiding machinery; the goal note is
not appended since activity.json carries it).
submitToChildChatlets the activity page'sSQ.choosebuttons speak into the chat. - Setup on the platform backend (
saveChild,setPin,verifyPin) isfamily.jsonthrough the workspace API, with the family server's hex SHA-256 PIN hashing, so the Parent Mode PIN gate works. - Presentations (
document.file) land in the child's viewer through the same selector the parent uses. The old per-activity polling, WhatsApp watch and fast-mode toggle are family-server only and skipped on the platform backend. - The worksheet/chat split now defaults to half the window.
The activity folder is the child conversation's whole workspace, so a
<name>-KEY.md written into it was readable by the child regardless of what
her prompt said. Keys now live in keys/<activity-slug>-KEY.md at the family
root: the parent prompt writes them there, create_learning_activity moves
any key it still finds in the folder (and reports answer_keys_moved_to),
and the parent prompt hook sweeps every activity folder once per process for
keys written before this change. The child prompt no longer needs the
"you cannot see the keys" sentence.
Child prompt (prompts/child.md) after review: the tool roster, the
"runtime's own shell" note, the "(I uploaded it to )" phrasing and the
raw-HTML colour instruction are gone (the shared markdown renderer does not
render raw HTML); the two skill-file reads are replaced by the rules that
mattered (JSXGraph for figures, self-contained pages, SQ.choose buttons,
<div class="q">); notify_user has a rule (upset, or stuck a long time);
the handoff opening message is named; sections reordered to identity →
answers → activity → showing things → format.
On the platform every activity keeps its own server-side conversation, so
"Start fresh" must rotate it: performHandoff calls
resetChildConversation (POST …/sparkquill-child/conversation/new with the
activity's key) before the child screen opens, and forgetChildChat makes
the host open the key's new session rather than the tab still on the old one.
The continue-or-fresh question is asked for the current activity and for any
activity that already has messages; a never-opened activity starts silently.
The activity's title, goal, pages and persona are rendered into the child
prompt from activity.json at prompt time (ChildPromptVariables), so the
goal no longer rides on the kickoff message or on the model reading the
manifest first.
internal/platformtools holds tool factories the platform owns; the server
registers them on the profile registry before any product. The first is
platform.suggest-actions (suggest_actions): a product binds it in its
manifest with interaction: {kind, render: chat.suggestions} and gets pills
without any code. ToolRuntimeContext.Product (the calling profile's
product) tags the emitted interaction. SparkQuill's own copy is gone; its
prompt still decides when the tool is called.
Family-server code paths stay in the learning app for now: the desktop
shell (desktop-sparkquill/main.js) still spawns family-server, so the
standalone backend is live until the desktop moves to the platform server.
Decision 2026-09-03: the desktop app keeps its own family server. The standalone backend and its renderers stay in the learning app; the platform backend is the hosted deployment. The two are not expected to have feature parity: the shared-chat work (ChatArea hosting, pills, product rows, turn blocks) is platform-only, and the family server keeps its own chat UI.
"Pulse" is called Check-in everywhere the parent sees it. On the platform
backend the popup is a skin over the manifest's pulse schedule
(product:sparkquill:pulse in the scheduler): on/off and Run now go to the
scheduler's enable/disable/trigger routes, the cadence is the product's
(shown, not edited), last run comes from the job, and watched websites live
in family.json watch_sites, which the parent prompt already reads. The
family server keeps its own cadence and preferred-hour controls.
open_workspace_view (registered for every workflow phase in
cmd/server/workflow_view_tool.go) emits a workflow.view presentation;
useWorkflowViewPresentations in WorkflowLayout turns it into the same
openWorkspaceView call the toolbar buttons make. The Go list of views
mirrors workspaceViews.ts and a vitest keeps them identical. This is the
product presentation mechanism applied to AgentWorks itself.
Reviewed the rendered interactiveWorkshopSystemTemplate (the workflow
builder/run system prompt, PhaseChatSystemPrompt in
pkg/orchestrator/agents/workflow/step_based_workflow). Fixes applied:
- The mode identity (
**WORKSHOP MODE**/**RUN MODE**— what the agent is right now) used to render 100+ lines after the## CURRENT MODE:heading that names it, separated by Execution policy, Deployed channel, and Reporting. It now sits directly under the heading, and the phase-detection paragraph, Foundation check, and Core loop are folded into the same workshop block instead of being scattered. -
WorkflowObjective/WorkflowSuccessCriteriawere computed server-side every turn and then never printed — the template told the agent to go re-readsoul.mdinstead. Now inlined directly under CURRENT STATE when both are present; the existing "ask the user" fallbacks for a missing objective/criteria are unchanged. - The agent can now open a toolbar view for the user
(
open_workspace_view, added earlier this session): a bullet in "Talking to the user" says to use it instead of describing where to click, and the Reporting section calls it out aftervalidate_report_html(). - Removed the duplicate "HTML report UI (brief)" section (the main
## Reportingsection already said the same thing for both modes). -
read_skill(skills=[{"name":"builder-reference","path":"references/X.md"}])appeared ~17 times; replaced with abuilder-reference/references/X.mdshorthand plus one unconditional sentence (right after the soul.md instruction) defining what the shorthand means, so it renders in every mode, not only the CLI/skill-projected branch that had its own (now-redundant) explanation. - Two leading-tab lines that rendered as literal Markdown code blocks (the Run Mode tool-availability block, and the Optimization section's "Hard rules" paragraph) are dedented.
- The standing "read builder/ conversations before every reply" instruction moved out of the Core loop into DEBUGGING, conditioned on actually having a repeated failure to check against.
- Trimmed CLI-internals trivia (the "~60-second silent MCP-call ceiling"
explanation) from the shared Tools cheat-sheet's Human attention bullet,
keeping only the actionable
timeout_seconds <= 45rule; and removed "deprecated and hidden ... testing focus" internal-status framing from bothGetSpecialWorkspaceToolsInstructionsandGetSpecialWorkspaceToolsPointerinpkg/instructions. -
PhaseChatSystemPromptnow collapses runs of 3+ newlines to one blank line (collapseBlankLines), a template-whitespace artifact from conditional blocks rendering empty in a given mode. - Not done: gating "Deployed channel runtime" on an actual connected Slack/WhatsApp signal — no such signal is computed at prompt-render time today (only a Slack webhook secret name on the manifest, no bot-route check), and wiring one is bigger than this pass. The section's wording already hedges ("Users may reach this workflow through...").
Addendum: refresh_workspace_view(view) is a second tool (decision
2026-09-03: view and refresh are different actions). It emits the same
workflow.view presentation with payload.action: "refresh"; the page
reloads an on-screen view (refreshWorkspaceView: the report re-reads its
HTML via its refresh event, every other view remounts on a token) and opens
a view that is not on screen. open_workspace_view never refreshes.
Every notify_user(notification_kind="run_summary") call (required at the
end of a Pulse cycle, normal after an ordinary run) already writes a
structured row into org_dashboard_notifications in the workflow's own
db/db.sqlite — the same file its report queries. The required activity/
actions report tab (Daily Action/Recent Activity, decided 2026-08-24)
now defaults to reading that table (notification_kind='run_summary')
instead of asking every builder to author "what happened" from scratch,
which had builders adding a step or table whose only job was feeding this
tab. A custom activity view is now something the parent explicitly asks
for, not the agent's default judgment call. Updated: reporting-policy.md,
design-reporting-ui.md, improve-report.md (item 8 now also flags a
report-only step/table as unnecessary complexity when it duplicates
run_summary data).
Decision (user), reversing 2026-09-03's "the desktop app keeps its own family
server": desktop-sparkquill will spawn the same two binaries the AgentWorks
desktop spawns (workspace-server + the agent_go server, see
desktop/main.js:1110-1285) with SparkQuill enabled as a product, and
cmd/family-server plus the standalone renderers in the learning app are
retired. Stated goal: "very less code duplication between SparkQuill and
AgentWorks — share the backend and frontend code as much as possible."
What prompted it: the desktop's bubble chat seen next to AgentWorks'
ChatArea — "this looks nothing like agentworks … i want only agentworks ui,
so we can maintain one single ui." The alternative — teaching family-server
the AgentWorks session protocol so PlatformChat could run against it — was
researched in full the same day (wire contracts, internal/events.EventObserver
reuse, /api/wp document mapping, ~12–15 days) and rejected: the hosted product
already works, and the shim would be a second engine kept in sync forever. The
research is kept in sparkquill_desktop_on_platform_plan.md.
Costs accepted: a heavier family install (two platform binaries and the
platform's startup subsystems), a one-time migration of ~/.sunlit-learning
into the workspace root under Chats/SparkQuill/ (flat activities/<slug>/),
and onboarding that satisfies the single-product deployment's token gate
(CLAUDE_CODE_OAUTH_TOKEN / setup token) plus WORKSPACE_API_TOKEN and
NATIVE_WORKSPACE. Open as of this entry: whether the learning app stays a
separate Vite build or becomes a product surface under frontend/src/products/
(the Video Studio / Dominion pattern), feature parity for WhatsApp, voice
model install and engine onboarding, and how the two desktop shells share
code.
Auto-synced from docs/ on main. Edit there, not here.