Osy#the first language built for agents
Agents firstAgentic appsWorkflowsDurable Execution — built inSecurityTestingThe editorThe UI modelOne program

Samples

wf-approvals

A purchase-order approval workflow — it parks and waits for a human at each step.

7 source files2 test files

The wf-approvals sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init wf-approvals
$ osy launch

The app

app.osy9 lines
// A purchase-order approval workflow — it parks and waits for a human at each step.
// The core human-in-the-loop / multi-wait workflow demo (PoApproval).
app WfApprovals {
  use Osyrin.Ui;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
  data  "data/**/*.json";
}
model/actions.osy26 lines
// The board's verbs, as ordinary app functions.

void ClaimLegal(PurchaseOrder po) { PoApproval.For(po).Legal.Claim(); }

void ReleaseLegal(PurchaseOrder po) { PoApproval.For(po).Legal.Release(); }

void DecideLegal(PurchaseOrder po, Decision decision, string reason) {
  PoApproval.For(po).Legal.Approve(decision, reason);
}

void DecideFinance(PurchaseOrder po, Decision decision, string reason) {
  PoApproval.For(po).Finance.Approve(decision, reason);
}

void CancelOrder(PurchaseOrder po) { PoApproval.RaiseCancel(po); }

void SubmitOrder(PurchaseOrder po) { PoApproval.RaiseSubmit(po); }

void RaiseOrder(string title, decimal total, PoCategory category) {
  var me = Session.CurrentUser;
  var account = Account.First();
  var po = new PurchaseOrder { Title = title, Total = total, Category = category,
                               Requester = me, Account = account };
  PoApproval.RaiseSubmit(po);
}
model/identity.osy68 lines
// The demo's identity model — WHO the people are, and on what authority.

enum Dept { Legal, Finance }

[Role] enum AppRole { Authenticator, Member, Support }

[Principal]
entity User {
  [Required, MaxLength(100)] string Name;
  [Required, MaxLength(200), Unique] string Email;
  [MaxLength(200)] string? PasswordHash;
  Dept Department = Dept.Legal;
  int  Seniority;
  bool IsLead;
  bool OnLeave;
  security {
    allow read when IsAuthenticated;
    allow read, create when IsAuthenticator;
    allow create when IsSupport;
    allow update when IsSupport;
    deny read PasswordHash when !IsAuthenticator;   // nobody but the auth flow ever sees the hash
  }
}

entity RoleGrant {
  [Required] User Grantee;
  [Required] AppRole Level = AppRole.Member;
  security {
    allow read when IsAuthenticated;
    allow create when IsAuthenticator;                 // …the signup, for the first grant
    allow create, update, delete when IsSupport;       // all three verbs, or the weakest is the way in
  }
}

policy IsAuthenticator => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Authenticator);
policy IsSupport       => RoleGrant.Any(g => g.Grantee == user && g.Level == AppRole.Support);

[AuthMethod]
string Signup(string email, string password) {
  var u = new User { Name = email, Email = email, PasswordHash = Security.HashPassword(password) };
  var grant = new RoleGrant { Grantee = u, Level = AppRole.Member };
  return Security.IssueJwt(u.Id, u.Email);
}

[AuthMethod]
string Login(string email, string password) {
  var u = User.Where(x => x.Email == email).FirstOrDefault();
  if (u == null) { Security.VerifyPassword(password); return ""; }
  if (u.PasswordHash == null) { return ""; }
  if (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

app.Auth = new PasswordAuth { LoginField = Email, PasswordField = PasswordHash };

app.AuthBootstrap = new AuthBootstrap {
  Role      = AppRole.Authenticator,
  Login     = Login,
  Signup    = Signup,
  LoginPage = LoginPage,
};

entity Account {
  [Required, MaxLength(40)] string Code;
  [Required] User Owner;
  security { allow read when IsAuthenticated; allow create when IsSupport; allow update when IsSupport; }
}
model/po_approval.osy123 lines
// PO APPROVAL — the core HITL surface (faithful to workflow_design/approvals.osy). A pool slot + a pre-assigned slot,
// an org SLA declared once and cascaded, reminders, a breach that widens or escalates, and cancel-from-anywhere.
enum PoStatus   { Draft, Review, Approved, Rejected, Cancelled, Escalated }
enum Decision   { Approve, Reject }
enum PoCategory { Capex, Opex, Services }

entity PurchaseOrder {
  [Required, MaxLength(200)] string Title;
  PoStatus   Status = PoStatus.Draft;
  PoCategory Category = PoCategory.Capex;
  decimal    Total;
  [Required] User Requester;
  [Required] Account Account;
  [MaxLength(500)] string? RejectionReason;
  User ReviewedBy;
  TimeSpan StateExpire;
  TimeSpan OverallDeadline;
  security { allow read when IsAuthenticated; allow create, update when IsAuthenticated; }
}

/// The external config a workflow is parameterised by — app data, editable by ops. Start {} snapshots the row.
entity ApprovalPolicy {
  PoCategory Category = PoCategory.Capex;
  TimeSpan   StateExpire;
  TimeSpan   OverallDeadline;
  security {
    allow read when IsAuthenticated;
    allow create when IsSupport;
    allow update when IsSupport;
  }
}

void Notify(User u) { Log.Information($"PO approval reminder — nudging {u.Id}"); }

workflow PoApproval {
  Tracks    = PurchaseOrder.Status;
  Autostart = this.Item.Total > 10000;
  Initial   = Draft;

  Start {
    var policy = ApprovalPolicy.Single(p => p.Category == this.Item.Category);
    this.Item.StateExpire     = policy.StateExpire;
    this.Item.OverallDeadline = policy.OverallDeadline;
  }

  Expire   = this.Item.StateExpire;      // DEFAULT deadline for EACH state  → `on Expire`
  Deadline = this.Item.OverallDeadline;  // the WHOLE INSTANCE               → `on Deadline`

  Assigned {
    Within = TimeSpan.FromHours(4);
    Remind Nudge(After = Within / 2, ThenEvery = TimeSpan.FromHours(1)) {
      foreach (var u in slot.Candidates) { Notify(u); }
    }
    Unassigned {                             // nobody picked it up — widen to the lead, keep waiting
      var lead = User.Single(u => slot.Candidates(u) && u.IsLead);
      if (lead == null || lead.OnLeave) return;      // no goto → keep waiting, keep reminding
      slot.Assign(lead);
    }
  }
  Finished {
    Within = TimeSpan.FromHours(8);
    Remind Nudge(After = Within / 2) { Notify(slot.Assignee); }    // one nudge to the assignee (a single principal)
    Unfinished { goto Escalated; }           // goto → the wait is over
  }

  event Approve(Decision decision, string reason);

  [Authorize(u => u == this.Item.Requester
                  || RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Support))]
  event Cancel();

  on Cancel { goto Cancelled; }
  on Deadline { goto Cancelled; }

  // region: draft-state
  state Draft {
    subscribe Submit();
    on Submit { goto Review; }
  }
  // endregion

  state Review {
    Expire = TimeSpan.FromDays(2);

    subscribe Approve(Decision decision, string reason) as Legal {
      Candidates = u => u.Department == Dept.Legal && u != this.Item.Requester;
    }

    subscribe Approve(Decision decision, string reason) as Finance {
      Candidates = u => u.Department == Dept.Finance;
      Assignee   = this.Item.Account.Owner;

      Finished { Within = TimeSpan.FromDays(2); }    // override just the clock; the rest cascades
    }

    on Legal(Decision decision, string reason) {
      when (decision == Decision.Reject) {
        this.Item.RejectionReason = reason;
        goto Rejected;
      }
      default {
        this.Item.ReviewedBy = Legal.Assignee;       // Legal approved → recorded, still waiting on Finance
      }
    }

    on Finance(Decision decision, string reason) {
      when (decision == Decision.Reject) {
        this.Item.RejectionReason = reason;
        goto Rejected;
      }
    }

    on Complete { goto Approved; }                   // default completion = ALL slots satisfied
  }

  event Submit();

  terminal success Approved  { }
  terminal cancel  Cancelled { Message = "purchase order cancelled"; }
  terminal error   Rejected  { Message = "purchase order rejected"; }
  terminal error   Escalated { Message = "approval SLA breached — escalated to a human"; }
}
model/theme.osy73 lines
// THE SHARED DEMO THEME — canonical copy. Every demo listed in `DemoSharedThemeTests` holds a
// byte-identical `model/theme.osy`, and that test is what keeps them identical.
//
// ⚑ EVERY COLOUR HERE SHADOWS A TOKEN THE KIT ALREADY DECLARES, and that is the whole point.
//   The themes this replaced declared a PARALLEL palette — `Surface0/1/2`, `TextPrimary`,
//   `FillAccent/Success/Warning/Danger` — names `Osyrin.Ui` has never heard of. So the kit's own
//   `Primary`, `Success`, `Warning` and `Danger` stayed at their defaults in six demos: the moment
//   one of them reached for a kit control, that control painted itself indigo next to the demo's
//   blue. `FillAccent` was declared in all six and referenced by NONE — an accent colour that
//   existed only in the theme file.
//
// So the rule for a demo theme is: SHADOW a kit token, never invent a second name for it. A name
// the kit does not have (`Radius.Card`) is a real extension and fine; a second spelling of one it
// does have (`TextPrimary` for `OnBg`) is how an app ends up with two of everything.
//
// Excluded by design — these five own their look and must NOT adopt this file: arcade, ember,
// motion, gestures (each demonstrates a visual world) and Apps/recall-osy.

theme Demo {

  Colors {
    // A deep petrol blue, deliberately not the kit's indigo (#4F46E5) — a demo should look like a
    // considered app rather than an unstyled one, and the two should be distinguishable on sight.
    // It is the one saturated colour on the page; everything else is warm neutral, which is what
    // keeps "distinct" from turning into "loud".
    Primary   = Modes.Of(light: Palette.From("#125E7A"), dark: Palette.From("#57B8D6"));
    OnPrimary = Modes.Of(light: "#FFFFFF", dark: "#06181F");

    Bg      = Modes.Of(light: "#FAF9F7", dark: "#101317");   // the page — a touch warm, so cards read as lifted
    Surface = Modes.Of(light: "#FFFFFF", dark: "#171C22");   // a card, a row, a lane
    Muted   = Modes.Of(light: "#EFEEEA", dark: "#1E242B");   // an inline notice, a disabled field
    Border  = Modes.Of(light: "#E4E2DC", dark: "#28303A");

    OnBg          = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    OnSurface     = Modes.Of(light: "#14181D", dark: "#E7EBF0");
    TextSecondary = Modes.Of(light: "#59626D", dark: "#9BA6B3");   // labels, timestamps, captions
    TextMuted     = Modes.Of(light: "#7C848E", dark: "#7E8894");   // the quietest text on the page

    Success = Modes.Of(light: Palette.From("#1D7A4C"), dark: Palette.From("#4EC98A"));
    Warning = Modes.Of(light: Palette.From("#B26A00"), dark: Palette.From("#E0A343"));
    Danger  = Modes.Of(light: Palette.From("#C1352B"), dark: Palette.From("#F0736A"));
  }

  // Names the kit does not have, so these are extensions rather than second spellings.
  Radius { Control = "8px"; Card = "12px"; }

  // ⚑ THE FONTS ARE WHAT MAKE THIS READ AS A DESIGNED APP RATHER THAN A DEFAULT ONE, and `Sans`
  //   shadows the kit's own token — so every kit control picks it up with no page edit at all.
  //   Both faces are vendored per app under `model/fonts/` and pinned in `osyrin.lock` (`osy font
  //   add`), because a webfont a page merely NAMES is a webfont that silently falls back.
    //   Only the faces the app SHIPS are named here — Mono is left at the kit default because no
  //   demo renders code, and naming an unshipped family is a silent fallback.
  //   Geist for the UI: a neutral grotesque with real character at small sizes, which is where a
  //   dense LOB screen lives. Fraunces for display: a warm variable serif, used ONLY on a page
  //   title. The pairing is the whole look — one voice for reading, one for announcing.
  Font {
    Sans    = "Geist, ui-sans-serif, -apple-system, \"Segoe UI\", Roboto, system-ui, sans-serif";
    Serif   = "Fraunces, \"Iowan Old Style\", Palatino, Georgia, ui-serif, serif";
  }

  FontSize   { Caption = "12px"; Body = "14px"; Subhead = "17px"; Title = "26px"; }
  FontWeight { Regular = 400; Medium = 500; Semibold = 600; }

  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
  // ── APP EXTENSIONS ─────────────────────────────────────────────────────────────────────────────
  // Everything ABOVE this line is the shared theme, held byte-identical across the demos by
  // `DemoSharedThemeTests`. An app may add tokens BELOW it — a name the kit does not have. It may
  // NOT add a second spelling of one the kit already has; that is the defect this file replaced.
}
model/pages/board.osy159 lines
// THE BOARD — and its job is not to move you through the flow quickly. Its job is to show that the SAME order looks
// different to different people, and that the difference comes from rules the workflow already declares.
using Osyrin.Ui;

[Page("/")]
[Render(CSR)]
[Title("Purchase approvals")]
component Board() {
  live var work = Workflow.Work<PurchaseOrder>()
                          .Include(r => r.Item)
                          .Include(r => r.Item.Requester)
                          .OrderBy(r => r.Remaining);

  live var orders = PurchaseOrder.Include(p => p.Requester).OrderByDescending(p => p.Total).ToList();

  live var people = User.ToList();

  var me = Session.CurrentUser;

  string title = "New laptops";
  decimal total = 25000;

  action Claim(PurchaseOrder po)   { ClaimLegal(po); }
  action Release(PurchaseOrder po) { ReleaseLegal(po); }
  action ApproveLegal(PurchaseOrder po) { DecideLegal(po, Decision.Approve, "looks fine"); }
  action RejectLegal(PurchaseOrder po)  { DecideLegal(po, Decision.Reject, "rejected by legal"); }
  action ApproveFinance(PurchaseOrder po) { DecideFinance(po, Decision.Approve, "budgeted"); }
  action RejectFinance(PurchaseOrder po)  { DecideFinance(po, Decision.Reject, "not in budget"); }
  action Cancel(PurchaseOrder po)  { CancelOrder(po); }
  action Submit(PurchaseOrder po)  { SubmitOrder(po); }
  action Raise()   { RaiseOrder(title, total, PoCategory.Capex); }
  action SignOut() { Session.SignOut(); }

  render {
    Stack(gap: 0, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) {

      Row(justify: Justify.Center, w: "100%", bg: Colors.Surface, borderW: 1, border: Colors.Border) {
        Row(align: Align.Center, justify: Justify.SpaceBetween, w: "100%", maxW: "980px", px: 5, h: "60px") {
          Text("Purchase approvals", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
          Row(align: Align.Center, gap: 3) {
            Text(me.Name + " · " + me.Department, fontSize: FontSize.Caption, color: Colors.TextSecondary);
            if (IsSupport) { Text("Support", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.Success); }
            else           { Text("Member", fontSize: FontSize.Caption, color: Colors.TextSecondary); }
            Pressable(onClick: SignOut) { Text("Sign out", fontSize: FontSize.Caption, color: Colors.TextSecondary); }
          }
        }
      }

      Row(justify: Justify.Center, align: Align.Start, grow: 1, minW: "0") {
        Stack(gap: 5, w: "100%", maxW: "980px", p: 5) {

          Stack(gap: 2) {
            Text("Open work", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold);
            Text("Every live approval slot, whoever holds it — and what YOU can do about each one.",
                 fontSize: FontSize.Caption, color: Colors.TextSecondary);

            if (work.Count() == 0) {
              Text("Nothing is waiting. Raise an order below to start one.",
                   fontSize: FontSize.Caption, color: Colors.TextSecondary);
            }

            foreach (var r in work) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) {

                  Stack(gap: 1) {
                    Text(r.Item.Title + " · " + r.SlotAlias, fontSize: FontSize.Body, fontWeight: FontWeight.Semibold);
                    Text(r.Item.Total + " · raised by " + r.Item.Requester.Name,
                         fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  }

                  if (r.SlotAlias == "Legal" && r.Assignee == null) {
                    if (PoApproval.For(r.Item).Legal.Candidates(me)) {
                      Button("Claim", onPress: () => Claim(r.Item), tone: Tone.Primary);
                    } else {
                      Text("open — not yours to claim", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                  }

                  if (r.SlotAlias == "Legal" && r.Assignee != null) {
                    if (r.Assignee == me.Id) {
                      Row(gap: 2) {
                        Button("Approve", onPress: () => ApproveLegal(r.Item), tone: Tone.Success);
                        Button("Reject", onPress: () => RejectLegal(r.Item), tone: Tone.Danger);
                        Pressable(onClick: () => Release(r.Item)) {
                          Text("release", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                        }
                      }
                    } else {
                      Text("held by " + people.Single(u => u.Id == r.Assignee).Name, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                  }

                  if (r.SlotAlias == "Finance") {
                    if (r.Assignee == me.Id) {
                      Row(gap: 2) {
                        Button("Approve", onPress: () => ApproveFinance(r.Item), tone: Tone.Success);
                        Button("Reject", onPress: () => RejectFinance(r.Item), tone: Tone.Danger);
                      }
                    } else {
                      Text("with " + people.Single(u => u.Id == r.Assignee).Name, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    }
                  }
                }
              }
            }
          }

          Stack(gap: 2) {
            Text("All orders", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);

            foreach (var po in orders) {
              Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
                Row(justify: Justify.SpaceBetween, align: Align.Center, gap: 4) {
                  Stack(gap: 1) {
                    Text(po.Title, fontSize: FontSize.Body, fontWeight: FontWeight.Medium);
                    Text(po.Status + " · " + po.Category + " · " + po.Total
                         + " · raised by " + po.Requester.Name, fontSize: FontSize.Caption, color: Colors.TextSecondary);
                    if (po.RejectionReason != null) {
                      Text("Reason: " + po.RejectionReason, fontSize: FontSize.Caption, color: Colors.Danger);
                    }
                  }
                  Row(gap: 2, align: Align.Center) {
                    if (po.Status == PoStatus.Draft) {
                      Button("Submit for approval", onPress: () => Submit(po), tone: Tone.Primary);
                    }
                    if (po.Status == PoStatus.Review && (po.Requester == me || IsSupport)) {
                      Button("Cancel", onPress: () => Cancel(po), tone: Tone.Danger);
                    }
                  }
                }
              }
            }
          }

          Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
            Stack(gap: 3) {
              Text("Raise an order", fontSize: FontSize.Subhead, fontWeight: FontWeight.Semibold);
              Text("Over 10,000 autostarts the approval workflow — that threshold is the workflow's `Autostart`.",
                   fontSize: FontSize.Caption, color: Colors.TextSecondary);
              Row(gap: 3, align: Align.End) {
                Stack(gap: 1, grow: 1) {
                  Text("Title", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  Input(value: title, label: "Title", placeholder: "What is being bought");
                }
                Stack(gap: 1) {
                  Text("Total", fontSize: FontSize.Caption, color: Colors.TextSecondary);
                  Input(value: total, label: "Total", placeholder: "25000");
                }
                Button("Raise", onPress: Raise, tone: Tone.Primary);
              }
            }
          }
        }
      }
    }
  }
}
model/pages/login.osy64 lines
// Sign-in. The demo ships several people in different departments and tiers, so this page's job is to let you be a
// DIFFERENT one — the whole subject is that the board looks different depending on who you are.
using Osyrin.Ui;

[Page("/login")]
[AllowAnonymous]
[Render(CSR)]
[Title("Approvals — sign in")]
component LoginPage() {
  string email = "lena@acme.test";
  string password = "demo1234";
  string problem = "";

  action SignIn() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "That email and password don't match anyone."; }
    else { Session.SignIn(ticket); }
  }

  render {
    Row(align: Align.Center, justify: Justify.Center, minH: "100vh", p: 4, bg: Colors.Bg, color: Colors.OnBg) {
      Stack(gap: 4, w: "100%", maxW: "420px") {

        Stack(gap: 1) {
          Text("Purchase approvals", fontSize: FontSize.Title, fontFamily: Font.Serif, fontWeight: FontWeight.Semibold, letterSpacing: "-0.02em");
          Text("Sign in as different people to see the same order treated differently.",
               fontSize: FontSize.Body, color: Colors.TextSecondary);
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 5, borderW: 1, border: Colors.Border) {
          Stack(gap: 3) {
            Stack(gap: 1) {
              Text("Email", labelFor: emailBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
              Input(value: email, id: emailBox, placeholder: "you@acme.test");
            }
            Stack(gap: 1) {
              Text("Password", labelFor: passwordBox, fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
              Input(value: password, id: passwordBox, type: "password");
            }
            if (problem != "") {
              Box(bg: Colors.Muted, rounded: Radius.Control, px: 3, py: 2) {
                Text(problem, fontSize: FontSize.Caption, color: Colors.Danger);
              }
            }
            Button("Sign in", onPress: SignIn, tone: Tone.Primary);
          }
        }

        Box(bg: Colors.Surface, rounded: Radius.Card, p: 4, borderW: 1, border: Colors.Border) {
          Stack(gap: 2) {
            Text("Seeded people — password demo1234", fontSize: FontSize.Caption, fontWeight: FontWeight.Medium, color: Colors.TextSecondary);
            Text("lena@acme.test — Legal, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("liam@acme.test — Legal, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("lars@acme.test — Legal lead, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("otto@acme.test — Finance, Member (owns the account)", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("bea@acme.test — raises the orders, Member", fontSize: FontSize.Caption, color: Colors.TextSecondary);
            Text("ops@acme.test — Support tier", fontSize: FontSize.Caption, color: Colors.TextSecondary);
          }
        }
      }
    }
  }
}