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

Samples

kanban

A kanban board — cards move between lanes, and a workflow drives the states.

4 source files1 test file

The kanban sample, running.
Running, signed in, compiled from the source below.

Get it

$ osy init kanban
$ osy launch

The app

app.osy8 lines
// A kanban board — cards move between lanes, and a workflow drives the states.
// It proves two things at once, and neither of them is "a board looks nice".
app Kanban {
  use Osyrin.Ui;
  model "model/**/*.osy";
  tests "tests/**/*.test.osy";
}
model/auth.osy91 lines
// The smallest real login — this app WRITES (moving a card), and the endpoint gate correctly refuses an anonymous one.
using Osyrin.Ui;

[Role] enum AppRole { Authenticator, Member }

entity RoleGrant {
  User Grantee;
  [Required] AppRole Level;
  security { }
}

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

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

// ⚠ THE TAKEN-EMAIL CHECK IS NOT A NICETY. `Email` is `[Unique]`, so without it a second signup on the same
// address fails at COMMIT — after this function has already handed back a ticket — and the page cannot tell the
// visitor anything useful. Answering "" here is what lets the form say the one thing that helps.
[AuthMethod]
string Signup(string email, string password) {
  if (User.Any(x => x.Email == email)) { return ""; }
  var u = new User { Email = email, PasswordHash = Security.HashPassword(password) };
  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 (Security.VerifyPassword(password, u.PasswordHash)) { return Security.IssueJwt(u.Id, u.Email); }
  return "";
}

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

[Page("/login")] [AllowAnonymous] [Render(CSR)]
component LoginPage() {
  string email = "you@kanban.test";
  string password = "demo-password";
  string problem = "";

  // ⛔ BOTH VERBS, because this page is the only door. It offered `Create account` alone, so the SECOND visit
  //    was a dead end: the address was taken, the signup answered nothing, and the form said "don't match
  //    anyone" — a message about signing in, on a page that could not sign anybody in. `Login` was declared and
  //    wired into `app.AuthBootstrap` the whole time; nothing called it.
  action DoLogin() {
    var ticket = Login(email, password);
    if (ticket == "") { problem = "No account with that email and password."; }
    else { Session.SignIn(ticket); }
  }

  action DoSignup() {
    var ticket = Signup(email, password);
    if (ticket == "") { problem = "That email is already registered — sign in instead."; }
    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: "26rem") {
        Stack(gap: 1) {
          Text("Kanban", fontSize: FontSize.Title, fontWeight: FontWeight.Semibold);
          Hint("Cards move between lanes, and a workflow drives the states.");
        }
        Card("Sign in") {
          Stack(gap: 3) {
            Field("Email", value: email, placeholder: "you@example.com", type: "email");
            Field("Password", value: password, type: "password");
            if (problem != "") { Hint(problem); }
            Row(gap: 2, align: Align.Center) {
              Button("Sign in", onPress: DoLogin, tone: Tone.Primary);
              Button("Create account", onPress: DoSignup);
            }
          }
        }
      }
    }
  }
}
model/board.osy208 lines
theme Studio {
  Colors {
    Bg        = Modes.Of(light: "#F6F7F9", dark: "#0E1116");
    OnBg      = Modes.Of(light: "#16181D", dark: "#E6EAF0");
    Surface   = Modes.Of(light: "#FFFFFF", dark: "#171B21");
    Border    = Modes.Of(light: "#E3E6EA", dark: "#2A2F37");
    Subtle    = Modes.Of(light: "#6B7280", dark: "#8A94A6");
    Accent    = "#4F46E5";
    OnAccent  = "#FFFFFF";
    LaneBg    = Modes.Of(light: "#EEF0F4", dark: "#1C212A");
  }
  Radius   { Card = "12px"; Control = "8px"; Pill = "999px"; }
  FontSize { Caption = "12px"; Body = "14px"; Section = "16px"; Display = "26px"; }
  FontWeight { Medium = "600"; }
  Length     { LaneW = "300px"; }
  Breakpoints { Cozy = 900; }
}

entity Lane {
  [MaxLength(40)] string Name;
  int Ordinal;
  [ForeignKey(Lane)] Card[] Items;
  security { allow read, create, update, delete when IsAuthenticated; }
}

entity Card {
  [MaxLength(80)] string Title;
  [MaxLength(40)] string Owner;
  Lane Lane;
  CardReview Review;
  bool Signed;
  security { allow read, create, update, delete when IsAuthenticated; }
}

enum CardReview { Drafting, InReview, Approved }

workflow CardFlow {
  Tracks    = Card.Review;
  Autostart = true;
  Initial   = Drafting;

  event Submit();
  event Approve();
  event Reject(string why);      // takes an argument, so a one-click move cannot complete it — `NeedsInput` says so

  state Drafting {
    subscribe Submit();
    on Submit { goto InReview; }
  }

  state InReview {
    subscribe Approve();
    subscribe Reject(string why);
    on Approve { when (this.Item.Signed) { goto Approved; } }
    on Reject  { goto Drafting; }
  }

  terminal success Approved { }
}

[Composable] component Column<T>(T[] cards, string title, int count) {
  render {
    Stack(gap: 2, p: 2, w: Length.LaneW, bg: Colors.LaneBg, rounded: Radius.Card) {
      Row(gap: 2, align: Align.Center, px: 1) {
        Text(title, fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
        Text(count, fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill);
      }
      Stack(gap: 2) {
        foreach (var c in cards) {
          Slot(c);
        }
      }
    }
  }
}

[Composable] component Board<T>(T[] lanes) {
  render {
    if (Layout.AtLeast(Cozy)) {
      Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 2, role: UiRole.Region, label: "Board") {
        foreach (var l in lanes) { Slot(l); }
      }
    } else {
      Stack(gap: 3, role: UiRole.Region, label: "Board") {
        foreach (var l in lanes) { Slot(l); }
      }
    }
  }
}

[Composable] component Spacer() { variants { base { Grow = 1; } } render { Box(); } }

[Page("/")]
component BoardPage() {
  live var lanes = Lane.Include(l => l.Items).OrderBy(l => l.Ordinal);

  on mount { SeedBoard(); }

  string moved = "";

  action Move(Card card, Lane from) {
    var next = lanes.Where(l => l.Ordinal > from.Ordinal).FirstOrDefault();
    if (next == null) { return; }
    moved = card.Title + " → " + next.Name;
    card.Lane = next;
  }

  action Save() { UnitOfWork.Commit(); }

  Card? picked;

  live var moves = CardFlow.For(picked).Transitions;

  action Pick(Card card) { picked = card; }

  action Take(Osyrin.Workflow.TransitionView m) {
    TakeMove(picked, m);
    moved = picked.Title + " → " + m.Target;
  }

  action Sign() { picked.Signed = true; UnitOfWork.Commit(); }

  string newTitle = "";
  action Add() { AddCard(newTitle); newTitle = ""; }

  render {
    Stack(gap: 5, p: 6, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) {
      Row(gap: 3, align: Align.Center) {
        Text("Sprint board", fontSize: FontSize.Display, fontWeight: FontWeight.Medium);
        Spacer();
        Field("New card title", value: newTitle, placeholder: "New card", onEnter: Add);
        Button("Add card", onPress: Add);
        Button("Save", onPress: Save);
      }
      Text(moved == "" ? "nothing moved yet" : "moved: " + moved,
           fontSize: FontSize.Caption, color: Colors.Accent, fontWeight: FontWeight.Medium);

      Stack(gap: 2, p: 3, bg: Colors.Surface, rounded: Radius.Card, borderW: 1, border: Colors.Border) {
        if (picked == null) {
          Text("select a card to see where it can go", fontSize: FontSize.Caption, color: Colors.Subtle);
        } else {
          Row(gap: 2, align: Align.Center) {
            Text(picked.Title, fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
            Text(picked.Review, fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.LaneBg, rounded: Radius.Pill);
            Spacer();
            Button("Sign", onPress: Sign);
          }
          if (moves.Count() == 0) {
            Text("nowhere — this card is finished", fontSize: FontSize.Caption, color: Colors.Subtle);
          }
          Row(gap: 2, align: Align.Center) {
            foreach (var m in moves) {
              Button(m.Target, onPress: () => Take(m),
                     disabled: !m.Allowed || m.NeedsInput,
                     whenDenied: m.NeedsInput ? "needs a reason" : m.Reason);
            }
          }
        }
      }

      Board(lanes: lanes) { lane =>
        Column(cards: lane.Items, title: lane.Name, count: lane.Items.Count) { card =>
          Stack(gap: 1, p: 2, bg: Colors.Surface, rounded: Radius.Control, borderW: 1, border: Colors.Border) {
            Pressable(onClick: () => Pick(card), label: "Select " + card.Title) {
              Stack(gap: 1) {
                Text(card.Title, fontSize: FontSize.Body);
                Row(gap: 2, align: Align.Center) {
                  Text(card.Owner, fontSize: FontSize.Caption, color: Colors.Subtle);
                  Spacer();
                  Text(lane.Name, fontSize: FontSize.Caption, color: Colors.Subtle);
                }
              }
            }
            Button("Advance", onPress: () => Move(card, lane),
                   announceAs: "Advance " + card.Title,
                   disabled: lane.Ordinal == lanes.Count(),
                   whenDenied: "already in the last lane");
          }
        }
      }
    }
  }
}

void TakeMove(Card card, Osyrin.Workflow.TransitionView move) {
  Workflow.Raise(card, move);
}

void AddCard(string title) {
  if (title == "") { return; }
  var first = Lane.OrderBy(l => l.Ordinal).First();
  var card = new Card { Title = title, Owner = "You", Lane = first };
  UnitOfWork.Commit();
}

void SeedBoard() {
  if (!Lane.Any()) {
    var todo = new Lane { Name = "To do", Ordinal = 1 };
    var doing = new Lane { Name = "In progress", Ordinal = 2 };
    var done = new Lane { Name = "Done", Ordinal = 3 };
    new Card { Title = "Measure the container", Owner = "Ada", Lane = todo };
    new Card { Title = "Stack lanes on a phone", Owner = "Grace", Lane = todo };
    new Card { Title = "Nested per-item template", Owner = "Alan", Lane = doing };
    new Card { Title = "Breakpoint token", Owner = "Katherine", Lane = done };
    UnitOfWork.Commit();
  }
}
model/states.osy84 lines
// THE SAME CARDS, ARRANGED BY WHAT THE WORKFLOW SAYS THEY ARE — and the board asks the engine which lanes will take
// them.

[Page("/states")]
[Title("By state")]
component StatesPage() {
  live var cards = Card.OrderBy(c => c.Title);

  on mount { SeedBoard(); }

  Card? held;

  live var moves = CardFlow.For(held).Transitions;

  string note = "";

  action Hold(Card c) { held = c; note = ""; }

  bool CanDrop(CardReview s) =>
    held != null
    && moves.Any(m => m.Target == s.Name && m.Allowed && !m.NeedsInput);

  string WhyNot(CardReview s) =>
    held == null ? "pick a card first"
    : !moves.Any(m => m.Target == s.Name) ? "the workflow has no move from here to " + s.Label
    : moves.Any(m => m.Target == s.Name && m.NeedsInput) ? "needs a reason, so a single gesture cannot complete it"
    : moves.Where(m => m.Target == s.Name).First().Reason;

  action DropInto(CardReview s) {
    var m = moves.Where(x => x.Target == s.Name).First();
    TakeMove(held, m);
    note = held.Title + " → " + s.Label;
    held = null;
  }

  render {
    Stack(gap: 5, p: 6, minH: "100vh", bg: Colors.Bg, color: Colors.OnBg) {
      Row(gap: 3, align: Align.Center) {
        Text("By state", fontSize: FontSize.Display, fontWeight: FontWeight.Medium);
        Spacer();
        Link("/") { Text("Back to the board"); }
      }
      Text(held == null ? "pick a card, then choose a lane that will take it"
                        : "holding: " + held.Title + " — lanes that will not take it say why",
           fontSize: FontSize.Caption, color: Colors.Subtle);
      Text(note, fontSize: FontSize.Caption, color: Colors.Accent, fontWeight: FontWeight.Medium);

      Row(gap: 3, align: Align.Start, overflowX: "auto", pb: 2, role: UiRole.Region, label: "States") {
        foreach (var s in CardReview.Members) {
          Stack(gap: 2, p: 2, w: Length.LaneW, bg: Colors.LaneBg, rounded: Radius.Card) {
            Row(gap: 2, align: Align.Center, px: 1) {
              Text(s.Label, fontSize: FontSize.Section, fontWeight: FontWeight.Medium);
              Text(cards.Where(c => c.Review == s).Count(),
                   fontSize: FontSize.Caption, color: Colors.Subtle, px: 2, py: 1, bg: Colors.Surface, rounded: Radius.Pill);
            }

            Button("Move here", onPress: () => DropInto(s),
                   announceAs: "Move to " + s.Label,
                   disabled: !CanDrop(s),
                   whenDenied: WhyNot(s));

            Stack(gap: 2) {
              foreach (var c in cards.Where(x => x.Review == s)) {
                Stack(gap: 1, p: 2, bg: Colors.Surface, rounded: Radius.Control, borderW: 1, border: Colors.Border) {
                  Pressable(onClick: () => Hold(c), label: "Pick up " + c.Title) {
                    Stack(gap: 1) {
                      Text(c.Title, fontSize: FontSize.Body);
                      Row(gap: 2, align: Align.Center) {
                        Text(c.Owner, fontSize: FontSize.Caption, color: Colors.Subtle);
                        Spacer();
                        Text(s.Label, fontSize: FontSize.Caption, color: Colors.Subtle);
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }
}