Build Flows

Custom applications · October 10, 2026 · 11 min read

Versioned Client Project Updates, Written by AI and Approved by People

Connect turns the weekly client update into a versioned object: engines compute the facts, a model rewrites only the prose, a person publishes, and the client reads the approved revision through a hashed share link.

By Charley Forey, founder of Build Flows

Every scheduling consultancy writes the same document over and over: the weekly or monthly update that tells an owner where the job stands. Someone pulls the latest schedule, compares it to last period, writes three paragraphs about progress and risk, pastes in a lookahead, and emails a PDF. It is slow, it varies by author, and once it is sent nobody can say exactly which version the client read.

In Connect we turned that document into a first-class object called an Update. An Update is project-scoped, versioned, reviewed by a person before any client sees it, and shared through a link that always serves the version that was approved. A model writes the prose. It never writes the numbers. This article covers the data model, the Draft to Published lifecycle, how generation gathers context, the four ways a draft gets started, and the share link and eval machinery around it.

What an Update actually contains

The most important design decision came first: the facts and the prose are produced by different things.

Deterministic engines that already existed in Connect produce the facts: the schedule comparison, the health grade, the forecast, the CPM and Monte Carlo work, and the schedule intelligence detectors that flag weather, RFI and delivery risk. A function called the assembler reads those engines and builds a complete Update document with templated narratives. That document is already shippable. If no model is configured, it is what the client gets.

The model's job is to rewrite the prose fields so they read like a careful project manager wrote them. The document is a structured JSON object stored on each revision:

SectionWhere it comes from
Summary, progress, health and forecast narrativesTemplated from numbers, then rewritten by the composer
Percent complete, tasks started, completed and slippedThe current capture and the schedule diff
Health score, grade and trendPortfolio health metrics
Forecast finish, a one-sided confidence band, pace finish, finish trendForecast history across recent captures
Changes, risks, opportunities, connector impactsThe diff and the detectors
Critical path, lookahead, behind-plan, baseline variance, schedule qualityComputed from the current capture
Sources, connected data, documentary evidenceThe schedules, connectors and project files in play
RecommendationsDetected by the engines, plus optional AI proposals flagged as such
Open questionsSuggested by the composer, up to five

Every number traces back to code a reviewer can reason about. That matters most here, because this is the agent output that leaves the building.

The data model: headers, revisions, events, shares

Updates arrived in one migration with five tables, and two small follow-ups added context and attachments.

TableRole
project_updatesOne header row per Update: status, period kind, current revision, published revision, the schedule and snapshots it was built from, headline health and forecast
project_update_revisionsImmutable numbered revisions. The full document lives here as JSON, with an editable label and a note
project_update_eventsAppend-only audit trail: who did what, tagged person, AI, client or system
project_update_sharesClient share links, stored only as hashes
project_update_cadencePer-project weekly and monthly generation settings

Two columns on the header carry the whole versioning story: current_revision and published_revision. Every generation or edit writes a new revision and advances current_revision. Publishing copies current_revision into published_revision. The client link always reads published_revision.

That split solves a problem that a single "version" column cannot. A project manager can open a published Update, fix a typo or rewrite a risk, and keep working on it for a day. The edit moves the Update back to Draft, but the client link keeps serving the version they were already sent until someone publishes again. Nothing the client sees changes without a deliberate act.

Status is a CHECK-constrained text column with five values: generating, draft, published, archived and failed. Period kind is weekly, monthly or adhoc. Revisions have a unique constraint on (update, revision) and are written inside a transaction that locks the header row first, so two reviewers saving at once cannot produce the same revision number.

The events table is never updated or deleted. It records every step from queued (with its trigger) through generated, edited, published, shared, client viewed and client acknowledged. When a client says "we were never told," the answer is a query.

The lifecycle, step by step

  1. Something asks for an Update. The module inserts a header with status generating and records a queued event stamped with the trigger. The request returns immediately.
  2. The worker picks it up. Generation is a facts assembly plus one model call, so it runs off the request path on the Postgres-backed worker, three per poll.
  3. The worker assembles facts, ranks recommendations, gathers human context, and runs the composer.
  4. In one transaction it writes revision 1, flips the header to draft, and stores the schedule, snapshots, health and forecast it used. A generated event records whether AI or the system produced it.
  5. A reviewer reads the draft, edits it if needed (each save is a new immutable revision), and publishes.
  6. The reviewer creates a share link and sends it. The client opens it, reads it, and optionally acknowledges it with a comment.

If assembly finds no current schedule, or anything throws, the header goes to failed with a plain-language error and a failed event. A failed Update is visible, not silently missing.

Gathering context

The assembler starts from a question that sounds simple: compared to what? The answer is a three-step fallback.

  • If the user picked a specific snapshot to compare from, use it.
  • Otherwise, use the snapshot that the last published Update was built on. This is the right default because it is what the client last saw. An unpublished draft from Tuesday does not count.
  • If there is no published Update yet, compare to the previous capture.

From there it reads the current schedule, portfolio health, the comparison, schedule intelligence, and up to eight captures of history for the forecast trend. It also lists the project's connected data sources, baselines and files as an evidence base.

Two kinds of human context get layered on top.

Notes. The user can type, paste or dictate context before generating: "the steel delivery was confirmed for the 14th," or an email from the owner. This is stored on the header so the worker has it at generation time, and the composer is told to treat it as authoritative background that must not contradict the figures.

Attachments. Files attached at generation time are uploaded into the project's normal Files, not into some Updates-only bucket. That means they index as evidence and every other agent can reuse them. The worker reads text from at most four of them, capped per file, and passes it to the composer as cited context.

Finally, recommendations get reordered by a learned score. Every time a reviewer acts on or dismisses a recommendation, an event records it with the recommendation's kind. Before composing, the worker aggregates that feedback per kind and demotes anything whose exact title was dismissed before. Nothing is dropped, only ranked, so a team that keeps dismissing weather nags sees them sink without the system deciding on its own that weather does not matter.

The composer: prose only, by construction

The Updates agent runs on our shared pi-agent-core runtime, not the Agents SDK loop that runs chat. It is not a tool loop. It is one structured completion: here are the facts as JSON, rewrite the prose fields, return the same shape.

The prompt tells the model never to change numbers. We do not rely on that. The response goes through a merge that takes only the narrative fields from the model and keeps everything else from the facts:

merged = {
  ...facts,
  summary: modelText(polished.summary) ?? facts.summary,
  progress: { ...facts.progress, narrative: modelText(...) ?? facts.progress.narrative },
  // same for health and forecast narratives
  recommendations: [
    ...facts.recommendations.map(polishTitleAndRationaleOnly),
    ...proposed.slice(0, 5).map(r => ({ ...r, impactDays: null, source: "ai" })),
  ],
}

If the model returns a different percent complete, it is ignored. If it returns broken JSON, the facts stand. If the call times out, the facts stand. A composer failure can make an Update read more plainly. It can never lose one or corrupt a figure.

The model may propose up to five additional schedule changes, grounded in activities named in the facts. Those come through with no impact estimate and a source of "ai", so the UI can show them as suggestions that need review rather than detected conditions. Each recommendation, detected or proposed, carries a hand-off note so a reviewer can open it in the Schedule Builder with the context pre-loaded.

A read-only "Ask about this update" mode answers team questions only from the Update's content, with question and answer both screened by the shared safety seam.

Four ways a draft gets started

Every path below produces a Draft. None of them publish.

TriggerWhat starts it
ManualA person clicks generate on the project's Updates tab, optionally with notes, attachments and a compare-from snapshot
ChatConnect AI calls the generate_project_update delegation tool
CadenceThe worker finds a project whose weekly or monthly slot has passed
DataA sync materially changes a project's schedule, or a job walk report completes

The chat path is worth a note. Most delegation tools in Connect pause the conversation until a person accepts a proposal. generate_project_update does not, because its output is already a proposal: a draft no client sees until someone publishes it. The tool checks the caller's access to Updates and to the project, enqueues the draft, and tells the user where to review it. Pausing would add a second approval step in front of the first.

The sync path has two brakes. A material-change gate compares the current schedule to its previous capture and only drafts if the finish moved, activities slipped, something became newly critical, or activities were added or removed. A floor then suppresses the draft if a system-generated Update landed for that project in the last seven days, so a daily sync cannot fill the review lane. The change summary becomes the draft's context, so the reviewer sees why it exists.

Cadence, per project and per program

Cadence is a row per project: weekly on/off with a weekday, monthly on/off with a day of month, and next-fire timestamps for each. The next-fire math is a pure function in UTC that fires at 06:00 on the chosen day and clamps day of month to 28, so February never skips a run. A test pins it by passing a fixed "now."

Each poll, the worker selects due rows, enqueues weekly and monthly drafts with a system actor, and advances the next-fire time from now. If an executive has paused the Updates agent for that workspace, the schedule still advances but nothing is generated. A pause that let cadences pile up would release a flood the moment it lifted.

Migration 061 added program-level cadence. A program owner can arm weekly updates across every project in a program in one action, which fans out to the per-project rows. The problem is that some project managers have already set their own schedule. So the cadence row gained an overridden flag. Setting cadence directly on a project writes overridden = true. The program cascade writes false and skips overridden rows unless the operator explicitly asks to include customized projects. The migration stamped every existing row as overridden, because every row that existed at the time had been set deliberately by hand.

Share links for clients

A client does not get a Connect account to read an Update. They get a link and a short code.

  • The link carries a random token. The code is short and avoids look-alike characters, because a person reads it off a screen and types it.
  • Both are stored only as hashes. The plaintext is returned once, at creation.
  • Shares expire after 30 days. A partial unique index allows only one un-revoked share per Update, and creating a new one revokes the old one.
  • Only a published Update can be shared, and the public page always renders published_revision.
  • Resolving a share returns the same not-found error for a bad token, a bad code, a revoked share or an expired one, so the endpoint reveals nothing about which part was wrong. The public endpoints are rate limited.

When the client opens the page, the share records last_opened_at and a client_viewed event. When they acknowledge, with or without a comment, the author gets a notification through the same feed as alerts. If a share sits unopened for five days, the author gets one nudge. The idea is that "sent" is not the end of the workflow. "Read and acknowledged" is.

Governing the agent

Because Updates go to clients, the agent sits under the same executive admin portal as every other agent, with a few extras.

Executives can publish a versioned config for it: a system prompt that sets the client-facing voice, a house-style playbook injected as knowledge, and a model and reasoning override. They can pause it for one workspace, which blocks manual generation, chat delegation, Ask, and cadence drafts.

A config change goes through the pre-publish regression gate described in our evals article. Only two agent kinds support regression runs: chat and Updates. For Updates, the worker re-runs the composer over golden facts prompts under both the draft config and the live one, and a judge grades each output against the ideal. A new house style that reads better but starts dropping open questions shows up before a client sees it.

The last signal is engagement. If clients stop opening or acknowledging shared Updates, the worker raises an item in the exec portal. Auto-drafting updates nobody reads is a cost, not a feature.

How this differs from a client portal

Our client portal guide covers what an owner-facing site should show, how to secure it, and when to buy instead of build. Updates is narrower: one document type with a full, audited lifecycle from generation to acknowledgement. If you are building a portal, an Update is the kind of object you would put in it.

Why this matters if you're building something similar

  • Separate facts from prose in the data path, not just the prompt. Assemble a complete document without the model, then merge back only the fields the model is allowed to touch. The model becomes an editor, not a source.
  • Keep "current" and "published" as two pointers. It lets people keep editing without changing what the client already has.
  • Make revisions immutable and events append-only. Disputes about what was communicated become queries.
  • Every automated trigger should produce a draft. Cadence, sync and chat all feed one review lane. Add a material-change gate and a floor before anything automated is allowed to create work for people.
  • Pick the compare-from point on purpose. "Since what the client last saw" is a better default than "since the last capture."
  • Measure whether the output lands. Opens and acknowledgements are the quality signal for anything client-facing.

Where to go next

Want client reporting like this on top of your own schedule data? Plan your build.

Frequently asked questions

Does the AI write the numbers in a client Update?

No. Schedule comparison, health, forecast and risk detectors compute every figure. The model receives those facts and may rewrite only the summary, narratives, recommendation wording and open questions. A merge step keeps every number from the facts even if the model returns something different.

What happens if someone edits an Update after it was shared?

The edit writes a new immutable revision and moves the Update back to Draft. The client link keeps serving the previously published revision until someone publishes again, so nothing the client sees changes without a deliberate action.

Can Updates be generated automatically on a schedule?

Yes. Each project can have a weekly and a monthly cadence, and a program owner can arm a cadence across all projects in a program while respecting projects with their own settings. Automated runs always create drafts for review, never published Updates.

How do clients view an Update without an account?

They receive a link containing a random token plus a short code. Both are stored only as hashes, links expire after 30 days, only one share per Update is active, and the page always renders the published revision. Clients can acknowledge receipt, which notifies the author.

How is a change to the Updates agent's prompt tested?

A draft config goes through a regression run: the composer is re-run over golden facts prompts under both the draft and live configs, and a judge grades each output against an ideal before the change is published.

Next step

Need something built around how your team works?

Describe the users, the workflow, and the systems it touches. We'll tell you whether a custom application makes sense and how we'd build it.

Prefer email? charley@buildflows.ai

Get the next guide in your inbox

Field Notes: practical guides and new walkthroughs, about once a month.

Field Notes

Practical guides and new walkthroughs on construction data and automation, roughly monthly.

Keep learning