okay-effects

okay-faces: user interfaces on algebraic effects

A Scala library in which an application is one program: it shows views and offers actions, and each face decides how — a desktop window, a terminal, a web page, HTML from a server, a REST API, an MCP server for an AI agent. The program names no face; a face is a handler of its one effect.

A program, and every face

A program is a value. fill asks for a form made from a type, confirm for a yes or no, show tells; nothing says how. Which face runs it is said once, where it is run. The block is the README's, compiled and run in okay-faces' build (sbt docsCheck/test).

import okay.faces.*
import okay.effects.shape.Shape

case class Who(name: String)
val who = Shape.derived[Who].labelled("name", "Name")   // one typed shape: the form, the JSON Schema, the decoder

val greeting: String ! Face =           // Face alone is the row
  fill[Who, Face](View.heading("Who are you?"), who).flatMap(w =>
    confirm[Face](View.text(s"Greet ${w.name}?")).flatMap(yes =>
      if yes then show[Face](View.text(s"Hello, ${w.name}!")).map(_ => "greeted") else pure("left")))

// every face is a handler of the one effect; the implementation is said once, where the program is run:
object AtTheTerminal:
  import okay.effects.machine.Machine.self
  import okay.faces.term.Terminal
  def main(args: Array[String]): Unit = run(Terminal.console(greeting))
  // or SwingFace.handle(greeting), McpFace.stdio(greeting), HtmlFace.serve(greeting, port, host),
  // RestFace.serve(greeting, port, host), LaminarFace.mount(greeting) in a page

// the test face plays it by a script, and tells what was shown — the reference every other face agrees with:
import okay.effects.machine.Machine.self
import okay.faces.test.Plays
val plays = Plays()
plays.handle(greeting)(plays.Script.of(Chosen("submit", okay.effects.json.Json.obj("name" -> okay.effects.json.Json.Str("Ann"))),
    Chosen.plain("yes"))) match
  case plays.Played.Finished(views, a) => assert(a == "greeted" && views.last == View.text("Hello, Ann!"))
  case other => assert(false, other.toString)

A form comes from one typed Shape: the same shape is the form a person fills, the JSON Schema an agent is given and the decoder of what comes back. A wrong value is refused with its problems and their paths, and the program stays on the same step.

The faces

Every face is a loop over one step machine — Waiting(view, actions, resume), Told(view, next), Done(a) — with the continuation kept. What a face adds is how it shows a view and how it reads a choice; the test face plays a program by a script, and every other face agrees with it.

FaceModuleWhat it is
testokay-faces-testa script of choices drives the program; what was shown, asserted
terminalokay-faces-terma line dialogue on the console; ANSI when asked
Swingokay-faces-swinga window on the JVM, forms from the shape, dressed by a look
Laminar, DOMokay-faces-laminar, -domScala.js in a page — with Laminar, or with no library at all; the browser's back, focus, keys
remotethe samethe same page over a program on a server, the browser knowing no application
HTMLokay-faces-htmlpages from the server, structural HTML5, no script; forms posted
RESTokay-faces-restthe dialogue as a JSON API with links and affordances (HATEOAS)
MCPokay-faces-mcpthe dialogue as tools for an AI agent, over stdio or HTTP with sessions

How it looks is data: a Look of theme tokens and style rules, compiled by each face to CSS, Swing or ANSI. The examples in the repository — noughts and crosses, a to-do list — run on every face, each main one line.

For AI agents: the MCP face

The agent sees, at every step, exactly the actions a person would see — so it cannot do more than a person, and the boundary is the program, not a prompt.

Actions as tools

The current step's actions are the tools, each with the JSON Schema of its shape, plus look and back. When the step changes, the list changes and the server says so (list_changed). For clients that read the list once, one tool act carries every action.

Results the model can read

Each result is the next view twice: Markdown to read, and a structured object with the view and the actions available. A rejected call is an error result listing the problems with their paths; the step is unchanged.

Sessions over HTTP

stdio, or the protocol's Streamable HTTP: initialize opens a session (Mcp-Session-Id), DELETE ends it; answers as JSON, or as an event stream when the client takes one.

Asking the person

Elicitation — a question to the user in the middle of a call, such as a consent. The program's continuation is parked between HTTP requests, no thread kept; the call goes on when the answer comes.

A described effect becomes tools too (McpFace.domain): every operation {domain}_{operation} with its parameters' and result's schemas. MCP itself is okay-mcp: JSON-RPC as data, a server a program over a link.

On Cloudflare Workers, one line

Where a program runs is a deploying, not a face. One more main serves it on Cloudflare Workers, each session a Durable Object (module okay-faces-cloudflare):

Cloudflare.serve(Door.shell(…), Door.html(program), Door.rest(program), Door.mcp(program))

A session's continuation is code and is never stored. What is stored is data: the last point the program said it is at, and the requests since. A new object starts the program at the point and plays those few requests, and is where it was — a question asked, too. The thin page is told by its session's socket when the session moved elsewhere.

A type served: REST resources and MCP tools

The other role: an effect described (okay-simple's Describe) is served with nothing more written. A type mapped by okay-store is described by its mapping, so it gets a REST API (RestDomains: the description at GET base, an operation at base/<name>) and MCP tools (McpFace.domain) — and is performed from afar by the same description (remote): a program in a page performs it, it happens on the server.

In a Worker or a Lambda, with the type's data in D1, Postgres or an object of its own: okay-serverless.