LearnRetrofit an Existing App

Retrofit an Existing App

This is the primary adoption path for existing apps.

Causet sits beside your app. Keep the frontend, API routes, database, queues, and third-party clients. Move one fragile workflow into a durable, inspectable timeline.

Your API route should accept the request. Causet should own what happens next.

Start small: one endpoint, one intent, one workflow. See What runs today? before you depend on Cloud or SDKs.


What problem this solves

Existing endpoints often hide multi-step workflows:

POST /api/access-requests
  → validate request
  → write DB row
  → call approval service
  → send email
  → update status
  → maybe fail halfway

When a step fails, it is hard to know what completed. Logs are not a timeline. Retry and repair become guesswork.

Causet turns that into a replayable timeline of intents and events — without rewriting the product.


When to use Causet

  • Multiple steps can partially fail
  • External services are involved (email, approvals, CRM, payments)
  • You need to explain what happened after an incident
  • You want replay, fork, or repair later
  • Webhooks or jobs will advance the same workflow later

When not to use Causet

  • Simple CRUD with no meaningful lifecycle
  • No need for audit, replay, or timeline inspection
  • Failures are cheap and easy to retry by hand
  • Everything must stay in one DB transaction you will not split

Full guidance: When Not to Use Causet.


Concrete example: access request

Existing endpoint

POST /api/access-requests

Old behavior

// Fragile: one route owns the whole workflow
export async function POST(req: Request) {
  const body = await req.json()
 
  await validateAccessRequest(body)
  const row = await db.accessRequests.insert(body)
  await approvalService.request(row.id)
  await sendEmail(body.email, 'Access requested')
  await db.accessRequests.update(row.id, { status: 'pending_approval' })
 
  return Response.json({ id: row.id, status: 'pending_approval' })
}

If sendEmail fails after approval was requested, status and reality diverge.

Causet behavior

POST /api/access-requests
  → validate request
  → submit request_access intent
  → return (compatibility or 202 accepted)

Causet
  → records intent
  → emits access_requested, approval_required, …
  → app receives updates (projection query, Kafka consumer, or webhook bridge)
  → frontend still reads existing DB / projection
  → failures are visible in the timeline and can be replayed

How to wrap the endpoint

// app/api/access-requests/route.ts — handoff boundary
import { serverEmitIntent } from '@causet/sdk-next/server';
 
export async function POST(req: Request) {
  const body = await req.json();
  const requestId = body.id ?? `ar_${crypto.randomUUID()}`;
 
  // Keep auth + shape validation in the app
  await validateAccessRequest(body);
 
  await serverEmitIntent('access_request_stream', requestId, 'request_access', {
    request_id: requestId,
    email: body.email,
    reason: body.reason,
  });
 
  // Compatibility mode: same-shaped success response
  return Response.json({
    id: requestId,
    status: 'pending_approval',
    message: 'Request received',
  });
}

SDK packages are Preview. Point CAUSET_API_URL at Causet Cloud (Early access) or a self-hosted stack when available. See What runs today? and Add Causet to Next.js.

Sample intent

actions:
  request_access:
    state: access_request
    entity_id_expr: intent.request_id
    input:
      request_id: { type: string, required: true }
      email:      { type: string, required: true }
      reason:     { type: string, required: true }
    core:
      rules:
        - name: record_request
          then:
            - op: set
              path: status
              value: pending_approval
            - op: emit
              event_type: access_requested
              payload:
                request_id: intent.request_id
                email: intent.email
            - op: emit
              event_type: approval_required
              payload:
                request_id: intent.request_id

Sample events

access_requested
approval_required
access_approved
notification_sent
notification_failed

Sample query / projection

projections:
  access_request_status:
    source_events: [access_requested, access_approved, notification_sent, notification_failed]
    target:
      table: access_request_status
      primary_key: [request_id]
 
queries:
  access_request_status:
    from: access_request_status
    where:
      request_id: { eq: input.request_id }

Your frontend can keep reading your existing table if you update it from Causet events — see Webhooks.


Compatibility mode vs true async mode

Compatibility mode (preserve synchronous API behavior)

Use when the existing client expects a normal success response.

{
  "id": "ar_123",
  "status": "pending_approval",
  "message": "Request received"
}
  • Safest first retrofit step
  • Frontend often needs no change
  • Causet runs the multi-step work after accept
  • Good for emails, Slack, CRM sync, enrichment, background side effects

True async mode (accepted / pending + notify)

Use when the workflow is long-running or the user needs progress.

{
  "status": "accepted",
  "requestId": "ar_123",
  "statusUrl": "/api/access-requests/ar_123"
}
  • Prefer 202 Accepted
  • Return a workflow or entity ID
  • Client polls, subscribes, or opens a status view
  • Notify the existing app via webhook/event bridge when status changes

How webhooks update the existing app

Causet records the timeline. Your existing app still owns user-facing tables if you want it to.

Bridge options today:

  1. Read Causet projections/queries from your API
  2. Consume Kafka ledger events and update your DB
  3. Outbound HTTP webhooks — Available now (see What runs today?)

Pattern for updating your DB from a Causet event (consumer or future webhook handler):

// Idempotent handler — store last processed event id
async function onAccessApproved(event: {
  eventId: string
  requestId: string
  status: string
}) {
  if (await alreadyProcessed(event.eventId)) return
 
  await db.accessRequests.update(event.requestId, { status: event.status })
  await markProcessed(event.eventId)
}

Details: Use Webhooks to Update Existing Flows.


Inspect timeline state

After you hit the endpoint:

causet inspect timeline --stream access_request_stream --entity ar_123

Or open Decision Timeline in Causet Cloud.

You should see the intent, emitted events, and any failed side effects — see Timeline.


Replay or repair failure

Example failure:

request submitted     ✓
approval succeeded    ✓
notification failed   ✗
projection stale      (still pending_notification)
  1. Inspect the timeline
  2. Fork from the last good cursor if needed
  3. Replay the notification step or rebuild the projection
  4. Apply the verified repair

Walkthrough: Replay.


How to roll out safely

  1. Pick one fragile workflow (access request is a good first pick)
  2. Keep the existing endpoint and public contract
  3. Submit one intent from the route
  4. Move downstream steps into Causet rules / sagas / side effects
  5. Bridge status back to your DB if the frontend still reads it
  6. Run against Causet Cloud (early access) or local runtime when available
  7. Inspect the timeline; simulate a failure; verify replay
  8. Ship behind a flag or single route
  9. Repeat for the next workflow

Checklist: Production Rollout Checklist.


What changed?

  • Frontend did not need a rewrite
  • Endpoint still exists — it is a boundary, not a workflow engine
  • Causet owns the durable workflow
  • Failures are visible on the timeline
  • Work can be inspected, forked, replayed, and repaired