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

Samples

wf-supplier-dispatch

wf-supplier-dispatch — CORRELATION: an inbound event finds its run by a BUSINESS KEY.

5 source files1 test file

Get it

// not one of the apps the toolchain ships: this one lives in the
// repository. Clone it, then:
$ cd demo/wf-supplier-dispatch
$ osy launch

The app

app.osy6 lines
// wf-supplier-dispatch — CORRELATION: an inbound event finds its run by a BUSINESS KEY.
app WfSupplierDispatch {
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}
model/dispatch.osy68 lines
// DISPATCH — a parcel, and a carrier that has never heard of our workflow.

enum ShipmentStatus { Booked, InTransit, OutForDelivery, Delivered, Lost }

entity Shipment {
  [Required, Unique, MaxLength(60)] string TrackingNumber;

  [Required, MaxLength(200)] string Destination;
  ShipmentStatus Status;

  /// Where the carrier last saw it. Written by the workflow from the scan, never by a page.
  [MaxLength(200)] string? LastSeenAt;
  /// How many scans we have accepted for this parcel — the carrier's view of the journey, as we received it.
  int ScanCount;

  security {
    allow read when IsAuthenticated;
    allow create, update when IsAuthenticated;
  }
}

workflow ParcelDispatch {
  Tracks    = Shipment.Status;
  Autostart = true;
  Initial   = Booked;

  [CorrelateOn(this.Item.TrackingNumber == trackingNumber)]
  event Scanned(string trackingNumber, string location, ScanKind kind);

  [Authorize(u => RoleGrant.Any(g => g.Grantee == u && g.Level == AppRole.Dispatcher))]
  event MarkLost(string why);

  on MarkLost(string why) { goto Lost; }

  state Booked {
    subscribe Scanned() as Collection;
    on Collection(string trackingNumber, string location, ScanKind kind) {
      this.Item.LastSeenAt = location;
      this.Item.ScanCount = this.Item.ScanCount + 1;
      goto InTransit;
    }
  }

  state InTransit {
    subscribe Scanned() as Movement;
    on Movement(string trackingNumber, string location, ScanKind kind) {
      this.Item.LastSeenAt = location;
      this.Item.ScanCount = this.Item.ScanCount + 1;
      if (kind == ScanKind.ArrivedAtFinalDepot) { goto OutForDelivery; }
    }
  }

  state OutForDelivery {
    subscribe Scanned() as Delivery;
    on Delivery(string trackingNumber, string location, ScanKind kind) {
      this.Item.LastSeenAt = location;
      this.Item.ScanCount = this.Item.ScanCount + 1;
      if (kind == ScanKind.Delivered) { goto Delivered; }
    }
  }

  terminal success Delivered { }
  terminal error   Lost { }
}

/// What the carrier's scan MEANS — their vocabulary, not ours, because it arrives in their payload.
enum ScanKind { Collected, InTransit, ArrivedAtFinalDepot, Delivered }
model/identity.osy54 lines
// Sign-in for the dispatch desk. The people here are OURS; the carrier is not a user of this app at all, and the
// next file is about why that distinction decides how the carrier gets in.

[Role] enum AppRole { Authenticator, Dispatcher }

[Principal]
entity Operator {
  [Required, MaxLength(100)] string Name;
  [Required, MaxLength(200), Unique] string Email;
  [MaxLength(200)] string PasswordHash;
  security {
    allow read when IsAuthenticated;
    allow read, create, update when IsAuthenticator;
    deny read PasswordHash when !IsAuthenticator;
  }
}

entity RoleGrant {
  Operator Grantee;
  [Required] AppRole Level;
  security {
    allow read when IsAuthenticated;
    allow read when IsAuthenticator;
    allow create when IsAuthenticator;
  }
}

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

[AuthMethod]
string Signup(string name, string email, string password) {
  var o = new Operator { Name = name, Email = email, PasswordHash = Security.HashPassword(password) };
  new RoleGrant { Grantee = o, Level = AppRole.Dispatcher };
  UnitOfWork.Commit();
  return Security.IssueJwt(o.Id, o.Email);
}

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

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

app.AuthBootstrap = new AuthBootstrap {
  Role   = AppRole.Authenticator,
  Login  = Login,
  Signup = Signup,
};
model/ingress.osy52 lines
// THE CARRIER'S DOOR — and the reason it is in THIS file rather than in the platform.
app.Secrets = [ new Secret("CarrierWebhook") ];

/// What the carrier POSTs. THEIR shape, not ours — a webhook contract belongs to whoever sends it.
class CarrierScan {
  [Required, MaxLength(60)]  public string TrackingNumber;
  [Required, MaxLength(200)] public string Location;
  [Required]                 public ScanKind Kind;
  /// The carrier's HMAC-SHA256 over the tracking number and location, keyed with the secret we exchanged at
  /// onboarding. Every real carrier webhook has one of these under some name.
  [Required, MaxLength(200)] public string Signature;
}

/// What we answer the carrier. `Accepted` false with `Retry` true is how a sender is told to come back — which is
/// what makes the NoSuchKey/NotRunning distinction below matter rather than being trivia.
class ScanReceipt {
  public bool Accepted;
  public bool Retry;
  [MaxLength(200)] public string Message;
}

/// The endpoint. Anonymous to the app's own identity model — there is no Operator behind a carrier's POST and there
/// never will be — and authenticated on the first line of its own body, by us.
[AllowAnonymous]
ScanReceipt Scan(CarrierScan scan) {
  var expected = Crypto.HmacSha256Hex(Secret.CarrierWebhook, scan.TrackingNumber + "|" + scan.Location);
  if (!Crypto.FixedTimeEquals(expected, scan.Signature)) {
    return new ScanReceipt { Accepted = false, Retry = false, Message = "signature rejected" };
  }

  var outcome = ParcelDispatch.CorrelateScanned(scan.TrackingNumber, scan.Location, scan.Kind);

  if (outcome == CorrelationOutcome.Deposited) {
    return new ScanReceipt { Accepted = true, Retry = false, Message = "recorded" };
  }
  if (outcome == CorrelationOutcome.NoSuchKey) {
    return new ScanReceipt { Accepted = false, Retry = true, Message = "unknown tracking number — try again later" };
  }
  return new ScanReceipt { Accepted = false, Retry = false, Message = "this parcel is no longer in transit" };
}

app.Apis = [
  new RestApi("CarrierApi") {
    Route   = "carrier",
    Version = "1.0",
    Auth    = new ApiAuth { ApiKey = false },
    Endpoints = [
      new Endpoint(Scan) { Method = HttpMethod.Post, Path = "/scans", SuccessStatus = 200 },
    ],
  },
];
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.
}