
Issho (一緒, "together") is collaboration for Phoenix: one engine for people and agents working together live, under every component on a page. Build apps like Notion, Figma or Linear, and bring any UI that follows the Issho contract: Kotoba's rich text, Masu's grids, Hakushi's canvas, or your own.
Status: early release (0.1). The server core, LiveView sessions, agents from Elixir and the browser client's core are here, and Hakushi's shared canvases run on them. The cursor overlay, following, the bindings of Kotoba and Masu, MCP and the installer come next; before 1.0, a minor version may change the API.
Installation
def deps do
[
{:issho, "~> 0.1"}
]
endIssho needs Elixir 1.18 or later, Phoenix 1.7.14 or later and, for
Issho.Live, Phoenix LiveView 1.1 or later.
It gives your app:
- rooms with your own authorization: one
authorize/3asked on join, on every message from a browser, and before each change goes out to one, so that a person whose access you take away stops receiving changes;Issho.revoke/3takes an actor out of a room now, on every node; - actors of any kind: users, admins, guests, services and agents,
through the
Issho.Actorprotocol, with no user model of Issho's; - documents with one authority each across a cluster, which orders every change, applies it once (a retry gets its receipt), saves it with your store, and only then confirms and broadcasts it; any revision of an epoch can be read again, for forms that publish exactly what a person saw;
- your tables as the truth when you want them: a document's
:on_transactionhook runs once for each accepted change, before anyone sees it, to save it (or refuse it), and a document withstore: :nonestarts from your data each time it opens; - engines for each kind of document, behind one boundary
(
Issho.Engine): the model is the binding's, everything else is Issho's; - presence on
Phoenix.Presence, and awareness (places, selections, locks) in batched frames: about 20 a second per session, whatever the size of the room; - LiveViews in the room (
Issho.Live): a LiveView is a session like a browser's, and the room's changes, people and awareness reach its components; - agents in the room from Elixir (
Issho.join_as/4): the same presence, places, locks, transactions and policy as a browser; - a browser client, built into the Hex package (
import ... from "issho"): a room per page shared by every component, its people and awareness, and shared documents whose changes show at once, wait while offline (kept across a reload of the tab), and replay over the changes of others; after a reconnect, only what the server does not have is sent; - engines in pairs, Elixir and TypeScript, held to one shared corpus of
cases (
test/corpus), so the browser and the server never disagree; Issho.Test, to start an isolated instance in a test and check that an engine replays.
A sketch
# In your supervision tree, after the PubSub:
{Issho,
name: MyApp.Issho,
pubsub: MyApp.PubSub,
endpoint: MyAppWeb.Endpoint,
policy: MyApp.Collaboration,
store: {MyApp.CollabStore, MyApp.Repo}}defmodule MyApp.Collaboration do
@behaviour Issho.Policy
@impl true
def authorize(who, "board:" <> id, action),
do: if(MyApp.Boards.allowed?(who.claims["user_id"], id, action), do: :ok, else: {:error, :forbidden})
@impl true
def document(_who, "board:" <> id),
do: {:ok, %{engine: Issho.Engine.Map, initial: MyApp.Boards.content(id)}}
end# A channel on a socket of your endpoint:
defmodule MyAppWeb.IsshoChannel do
use Issho.Channel, instance: MyApp.Issho
end
# A LiveView signs the browser's join token:
token = Issho.Token.sign(MyApp.Issho, current_user, "board:42",
can: :write, claims: %{"user_id" => current_user.id})# An agent joins the same room:
{:ok, claude} = Issho.join_as(MyApp.Issho, agent, "board:42", on_behalf_of: current_user)
{:ok, _doc} = Issho.Participant.open(claude, "board:42")
{:ok, _receipt} = Issho.Participant.transact(claude, "board:42", ops)In the browser
import { joinRoom } from "issho"
const room = await joinRoom("board:42", { token, self: "user:ada" })
const doc = await room.open("board:42")
doc.on((event) => event.type === "change" && render(event.state))
doc.change([{ op: "set", path: ["title"], value: "Q3 plan" }])
room.aware({ place: { doc: "board:42", at: { path: ["title"] } } })
room.awareness.on((peers) => drawCursors(peers))
room.presence.on(() => drawPeople(room.presence.others))Development
mix deps.get
npm ci --prefix assets
mix test # includes the end-to-end test of the client on a real endpoint
mix test.js # the browser client's unit tests
mix precommit # compile with warnings as errors, format, credo, test
MIX_ENV=dev mix issho.build # the bundles, into priv/static
License
MIT. See LICENSE.