Tide

Driving Argon

Typed, journaled ontology state from a workflow — defineWorkflow and the in-process Argon client.

Overview

A Tide workflow that drives an Argon ontology gets two ergonomic guarantees:

  • defineWorkflow — a typed authoring entry that gives the workflow body a discoverable ctx instead of ambient globals, with the input schema flowing into the body's first argument.
  • A typed, in-process Argon clientctx.argon carries mutations / queries / computes generated from the ontology's pub surface. A wrong field or path is a type error in the editor, not a runtime 400. Every read and write routes through Tide's journaled, replay-deterministic ops — never fetch — so determinism and fork isolation hold invisibly.

Both are pure sugar over the core export default { schema, main } form; no runtime protocol changes. The core form keeps working unchanged.

A complete workflow

import { defineWorkflow, S } from "tide";
// The typed client `tide run` / `tide test` generate into `.tide/`.
import { Company, Person, sdk } from "../.tide/argon-client.ts";

export default defineWorkflow({
  argon: sdk,
  input: S.object({
    company: S.text(),
    name: S.text(),
    salary: S.integer(),
  }),
  async run({ company, name, salary }, { argon, log }) {
    const employer = Company.ref("#i1");
    const employee = Person.ref("#i2");

    // World-state writes run at the top level, NOT inside step(): the runtime
    // journals each write and replays it verbatim on resume (exactly-once), so a
    // step() wrapper is unnecessary — and a write inside step() is rejected,
    // because a step retry cannot roll back the underlying world-state write.
    const receipt = await argon.mutations.hire.receipt({
      employer,
      employee,
      employer_name: company,
      employee_name: name,
      salary,
    });
    log("hired", name, "— events:", receipt.eventsEmitted.length);

    // Read-your-writes within the run: the query sees the hire.
    const employees = await argon.queries.all_employees.run({});
    return { hired: name, headcount: employees.length };
  },
});

Run it from the project root (the directory holding ox.toml):

tide run workflows/onboard.ts --input '{"company":"Acme","name":"Ada","salary":120000}'

defineWorkflow

defineWorkflow({
  argon?: sdk,                // the generated typed client (omit for pure compute)
  input?: S.object({ ... }),  // input schema; defaults to S.object({})
  run(input, ctx) { ... },    // input is typed via Infer<typeof input>
});

It returns { schema, main }, so a workflow file still export defaults the result. run receives:

  • input — the validated, defaulted input, typed from the input schema.
  • ctx — the workflow context:
FieldWhat it is
argonThe typed, in-process Argon client (only when an argon SDK was passed; a lazy getter — a workflow that never reads it needs no backend).
stepJournaled, replayable step runner.
hitlHuman-in-the-loop wait (hitl.wait(...)); suspends the run for tide resume.
llmLarge-language-model calls (ai.llm).
aiThe full ai.* surface (llm, agent, tool, code).
log, sleep, readFile, writeFile, …The sandbox capabilities.

The typed Argon client

tide run and tide test compile the project ontology to a .oxbin and generate a typed client into .tide/ — both in-process, using the Argon crates compiled into the tide binary (no external ox toolchain). The output matches ox gen --target ts / ts-client:

  • .tide/argon-client.ts — the typed facade (mutations / queries / computes + entity classes and sdk).
  • .tide/sdk.ts — the dependency-free SDK it imports.

It is regenerated whenever the ontology is rebuilt, so the typed surface always tracks the live ontology. The directory is build output (gitignored); commit the .ar sources, not the generated client.

defineWorkflow({ argon: sdk }) calls sdk.use(...) with Tide's in-process runtime client. The bound client exposes, per pub declaration:

await argon.mutations.hire.run({ ... });      // returns the mutation's result
await argon.mutations.hire.receipt({ ... });  // returns the full receipt
await argon.queries.all_employees.run({});    // returns the typed rows

Use connectArgon(sdk) to bind the client outside a workflow body (for example in a tide test file):

import { connectArgon } from "tide";
import { sdk } from "../.tide/argon-client.ts";

const argon = connectArgon(sdk);

Individual references

Argon individual references are strings of the form #i<n> (#i1, #i2, …). The generated entity classes give typed constructors:

const employee = Person.ref("#i2");

Determinism rules

The typed client is durable and deterministic by construction — you never see ordinals or journals:

  • A mutation is journaled. On replay (after a crash or a tide resume) its recorded receipt is returned verbatim; the mutation never re-runs.
  • A query within a run reads at the run's pinned transaction watermark, so a replayed run observes the same data the original run did, even if main has moved on. Reads inside the run also see the run's own writes (read-your-writes).
  • A run's writes land in a per-run fork and reach main only when the run completes successfully; a failed run's writes are discarded. The fork is durable: a run that suspends (a HITL wait) or crashes before completing reattaches to the same fork on resume, so it reads back its own pre-suspend writes.

Run Argon writes (argon.mutations.*, state.dispatchMutation, …) at the top level, not inside step(...): the runtime already journals each write and replays it verbatim on resume, so it is exactly-once without a step() wrapper — and a write inside step() is rejected, because a step retry cannot roll back the underlying world-state write (it would double-apply). Reserve step(...) for non-Argon side effects (an HTTP call, a file write you depend on) so those, too, are journaled and replay-stable.

main is durable across invocations: the embedded engine persists main's event log under <project>/.tide/world and replays it on open, so a run's promoted writes are read by every later tide run. Seed a world from one workflow, then read those pre-existing instances from another. --world-dir <dir> roots the world elsewhere; --fresh wipes it. tide test runs against a throwaway store, so tests never read or pollute the persistent world.

Testing locally

tide test discovers *.test.ts / *.spec.ts, builds the ontology, and runs each test case against the real Argon reasoner in its own per-run fork over an ephemeral world store — one case's writes never leak into the next, never reach main, and never touch the persistent .tide/world, so a suite is order-independent and deterministic regardless of accumulated world state:

import { describe, expect, test } from "tide:test";
import { connectArgon } from "tide";
import { Company, Person, sdk } from "../.tide/argon-client.ts";

const argon = connectArgon(sdk);

describe("employment onboarding", () => {
  test("hire records a person", async () => {
    const receipt = await argon.mutations.hire.receipt({
      employer: Company.ref("#i1"),
      employee: Person.ref("#i2"),
      employer_name: "Acme",
      employee_name: "Ada",
      salary: 120000,
    });
    expect(receipt.eventsEmitted.length > 0).to.equal(true);

    const employees = await argon.queries.all_employees.run({});
    expect(employees.length).to.equal(1);
  });
});
tide test            # human-readable
tide test --json     # structured results (for tooling / agents)

For coding agents

The workflow manifest and the test output are structured for programmatic use:

  • Workflow shape. A workflow module export defaults { schema, main } (what defineWorkflow returns). tide schema <file> prints the input schema as JSON.
  • Typed surface discovery. After a build, .tide/argon-client.ts enumerates every callable: sdk.mutations.*, sdk.queries.*, sdk.computes.*, with parameter and result types. Read it to discover the exact ontology surface.
  • Structured test output. tide test --json emits { status, suites: [{ filePath, status, cases: [{ name, fullName, status, error }], output, error }] }. status is "passed" / "failed"; error.message carries the assertion failure.

On this page