A canvas that several people open at once, as a shared design file is: each sees the others' changes as they happen, their cursors, the nodes they selected and those they drag, in their colours.
A shared canvas is a room of Issho, collaboration for Phoenix: Hakushi on its own is a canvas for one person, and Issho makes it a canvas for many, with the people's own permissions and no service besides your app.
<.canvas
id="schema"
graph={@schema.graph}
on_change={&Schemas.change(&1, &2, @schema)}
issho={[instance: MyApp.Issho, document: "schema:#{@schema.id}"]}
user={%{id: @current_user.id, name: @current_user.name}}
>issho takes the keys of every component of an Issho page (Issho.Join,
the same for Kotoba's editor and Masu's grid): :instance and
:document (required: the canvas's document; its room is the part
before the first "/", so the blocks of a page whose documents share a
room share its people); :can, :read for a viewer (as readonly);
:people, false to hide the canvas's avatars on a page that lists the
room's people (Issho.Components.people/1); :follow, false for a
canvas that takes no part in following; :on_behalf_of and :claims,
for your policy. Any other key raises.
Setting up
Issho in your dependencies:
{:issho, "~> 0.4.2"}An Issho instance in your supervision tree, after the PubSub, with your policy: who may open which canvas, and change it.
{Issho, name: MyApp.Issho, pubsub: MyApp.PubSub, endpoint: MyAppWeb.Endpoint, policy: MyApp.Collaboration}defmodule MyApp.Collaboration do @behaviour Issho.Policy @impl true def authorize(%Issho.Identity{key: user_id}, "schema:" <> id, action) do if MyApp.Schemas.allowed?(user_id, id, action), do: :ok, else: {:error, :forbidden} end endactionis:join(open the canvas, see who is here),:read(receive the changes),:write(change it),:lock,:comment(write in the room's threads, see Comments) or:interrupt(pause or stop someone else's agent). The policy is asked as each person opens the canvas, and again for each change, in both directions: a person whose access you take away stops seeing the changes, andIssho.revoke(MyApp.Issho, topic, user_id)takes them out of the room at once.The hook, in the LiveView or its
live_session:live_session :default, on_mount: [Hakushi.Live] do(
Hakushi.Liveis Issho's hook,Issho.Live: a LiveView with either needs no other.)
The room's document
The canvases of a topic share one document, the graph, with one process in your cluster that puts the changes of everyone in order. It runs while anyone has the canvas open, wherever: on its own page, embedded in a document of another page (see In a page), or for an agent. Nobody needs to be on the canvas's page for the others to share it.
What the document is (its graph, rules and on_change) comes from your
Issho policy when it says, whoever opens it first:
defmodule MyApp.Collaboration do
@behaviour Issho.Policy
@impl true
def document(_who, "schema:" <> id) do
schema = MyApp.Schemas.get!(id)
Hakushi.Issho.document(schema.graph, rules: [cycles: false], on_change: &MyApp.Schemas.change(schema, &1, &2))
end
endDefine it there when the canvas opens in more than one place, or for
agents: it is then the same document whichever opens it first, and an
agent works on it with no browser at all. Without it, the first canvas to
open the topic gives it its graph, rules and on_change, the others
get the graph as it is then, and their own rules and on_change are not
used: two places that pass different ones would differ by who came first.
Each change of each person goes to that process, which checks the rules,
applies it, asks on_change (once, whoever made it, with the graph
before and after it), and only then sends it to every canvas of the
topic. So on_change runs once for each change, and two people never
save over each other: the second change applies to the graph the first
one made. A change that no longer makes sense (an edge to a node someone
deleted a moment before) is refused, with the reason.
The document lasts while a canvas has it open, and a few seconds more,
so that a reload finds it. Your tables, which on_change writes, are
the truth: the next canvas to open the topic starts from the graph you
give it.
People
user is your user: a struct of yours for which you implement
Issho.Actor (its key, "user:4", and what others see of it), or a map
with an :id, and a :name, a :color (any CSS colour) and an
:avatar (an image's URL) if you have them. Give the canvas the same
user as your other blocks (a Kotoba editor, Issho's people list), and
the person is one person in all of them. A person with no colour gets
one of Issho's eight (--issho-person-1 to -8, the same as in Issho's
lists), else one of Hakushi's, by their key. A person with the canvas
open in two tabs is one person, and does not see themselves. Without a
user, a canvas joins as someone anonymous.
A read-only canvas (readonly, or issho's can: :read) is a viewer of the room: Issho's lists
show them viewing, their cursor still goes to the others, and the room
takes no change from them.
The avatars of the others show in the corner of the canvas (not with
people: false). Draw them yourself with the :person slot, which gets
a map with "id", "name", "color" and "avatar".
Cursors and drags
Each person's cursor, the nodes they selected and the nodes they drag go to the others as they move, in Issho's awareness frames: one frame of what changed, about twenty times a second, whatever the number of people. A node someone drags moves for everyone while they drag it, with their name; it lands where they drop it when the room has the move.
A line someone draws with a kit's tool (ink, Hakushi.Kits.Ink) goes to
the others the same way, as they draw it: each canvas draws it under
their cursor, in its colour, and it becomes a change of the room's when
the pen lifts.
They are the canvas's own keys of the person's state, under the canvas's
document ("blocks", see Issho.Awareness): cursor, selected,
viewport, dragging and ink. Issho's own keys (place, pointer, view,
motion) stay Issho's, so the other blocks of the page never collide
with the canvas's.
A place on the canvas
Where someone is on the canvas, in a pointer, in an agent's motion or in
what your app keeps, is a place of the canvas's document, in the
canvas's own terms (never screen pixels, as each person has their own
pan and zoom). Its format is part of Hakushi's API, and only grows
(Hakushi.Issho.Place):
%{"point" => [x, y]} # a point, in the canvas's coordinates
%{"node" => node_id} # a node
%{"nodes" => [node_id, ...]} # nodes (the first is where it is drawn)A cluster
The room's process is found from every node of a cluster (:global,
unless your Issho instance names another registry), and so is each agent
in the room; the PubSub must reach every node, as Phoenix's does.
Events
Each change of a shared canvas is an event of its room
(Issho.Event), once the room has it, named for what the change did:
| Event | When | data |
|---|---|---|
canvas.added | nodes added: dropped, pasted, an undone delete | %{nodes: [%{id, type}], edges: [%{id, source, target}]} |
canvas.connected | edges drawn | %{edges: [%{id, source, target}]} |
canvas.reconnected | an edge's end moved to another node | %{edges: [%{id, source, target}]}, its new ends |
canvas.moved | nodes moved or resized | %{nodes: [%{id, x, y}]}, with width and height for a resize and parent for a node put in a group or out; the nodes inside a moved node too |
canvas.updated | nodes or edges changed: a node's data, an edge's label | %{nodes: [id], edges: [id]} |
canvas.deleted | nodes and edges deleted | %{nodes: [id], edges: [id]}, with the nodes inside and the edges that went with them |
The event's document is the canvas's, and its by who made the change
(a person, an agent and whom it acts for, your server). A change of the
paper makes none of its own: Issho's document.changed says it, as it
says every change.
An agent hears what people do on the canvas, and acts on it:
wait_for_events {"room": "schema:42", "names": ["canvas.*"]}A component of the page hears them (Issho.Live.subscribe/3), and so
does your app, for a log, a notification, a count:
Issho.Events.subscribe(MyApp.Issho, "schema:42")
def handle_info({Issho, :event, %Issho.Event{name: "canvas.deleted", data: data}}, socket) do
...
endComments
Issho's comments (Issho.Components.comments/1) go on the canvas as on
any block of the page: a person selects nodes, writes, and the thread is
on them, with a mark at the node on each person's screen, where it is in
their pan and zoom. Hakushi says where its places are, so the marks need
no code of yours. Give the panel the canvas's words for a place with the
:place slot and Hakushi.Issho.Place.label/3:
<.canvas id="schema" graph={@graph} issho={[instance: MyApp.Issho, document: @doc]} user={@current_user} />
<.comments id="comments" room={@doc}>
<:place :let={%{place: place, default: default}}>
{Hakushi.Issho.Place.label(place, @graph, document: @doc) || default}
</:place>
</.comments>A thread on a node says The node “customers” (its name as the canvas
says it to a screen reader: give label/3 the canvas's kits and
node_label if it has them), on several nodes The node “customers” and 2 more, on a point A point on the canvas, and the panel's own words
when the nodes are gone.
The graph is the room's, as it is now: keep it with the canvas's events, in the LiveView that is in the room.
def mount(_params, _session, socket) do
doc = "schema:#{socket.assigns.schema.id}"
if connected?(socket) do
{:ok, _session} = Issho.Live.join(MyApp.Issho, socket.assigns.current_user, doc)
Issho.Live.subscribe(doc, :self, events: ["canvas.*"])
end
{:ok, assign(socket, doc: doc, graph: socket.assigns.schema.graph)}
end
def handle_info({Issho.Live, "event", _room, %Issho.Event{}}, socket),
do: {:noreply, assign(socket, :graph, Hakushi.Issho.graph(MyApp.Issho, socket.assigns.doc) || socket.assigns.graph)}
# The room's other news (its people, their cursors) is the canvas's.
def handle_info({Issho.Live, _event, _room, _payload}, socket), do: {:noreply, socket}The threads are kept in your Issho instance's store (:store), whatever
the canvas's document is. See Issho's comments guide for who may
comment, mentions, and agents that answer.
The app's own changes
In the LiveView of the canvas, Hakushi.Live.change/2. From anywhere (a
job, another LiveView), Hakushi.Issho.change(MyApp.Issho, document, change, author: current_user), while someone has the canvas open; it
answers {:error, :not_running} when no one has, and then you change
your own tables. :author is a key ("server", the default) or your
actor: on_change gets it as by, as everyone's. Hakushi.Issho.graph/2 reads the graph a room has.
Agents
An agent works on a shared canvas as a person does, and the people see
it: its avatar in the corner, its pointer with its name, the node it
drags going with its pointer, its changes landing. Hakushi.Issho.tools/0
gives it the canvas's verbs, however it reaches your app:
| Tool | What it does |
|---|---|
read_canvas | the nodes (place, size, data, group) and edges |
point_at | moves its pointer to a node or a point, and selects nodes |
add_node | a node of a type the canvas draws, at a place |
move_node | drags a node to a place: the people see it go |
connect_nodes | an edge, from a handle to a handle |
update_node | a node's data, type or lock |
delete_items | nodes, with their edges, and edges |
draw_on_canvas | strokes of ink, or a ring around a node: the people see the pen draw |
draw_shape | a shape (rectangle, ellipse, diamond, triangle, text) or a line of its own, with its text and style; or a shape's text, style and rotation |
add_image | an image from an address of a host the app allows, or a file of the app's by name, with its alt text and caption: the people see its placeholder while it comes |
A person may pause or stop an agent at work (Issho's agents bar,
Issho.Work): then it reads the canvas and does nothing else on it, not
even go there, and the tool tells it why, as Issho's own tools do.
Its pointer goes to where it works and drags as a hand does, in a straight line: only where it goes is sent, and each browser draws the way there (the pointer, and the node it drags), so the people follow it. Others' pointers glide the same way, which smooths a person's too. For changes that land at once:
config :hakushi, Hakushi.Issho, travel: falseIt moves with Issho's motion (Issho.Motion): only when someone of the
room sees the canvas, within a tool call's time budget.
An agent that draws
On a canvas with ink (Hakushi.Kits.Ink), draw_on_canvas draws strokes
through points of the canvas, or a ring around a node, to circle it.
The people see its pen go to each stroke and draw it, the line growing
under its pointer as a person's does, and each stroke lands as a node
when the pen lifts: they move it, erase it and undo it as any.
Whether the people watch it draw is the app's to say, and the agent's for one drawing:
config :hakushi, Hakushi.Issho,
ink: [
live: true, # false: strokes land at once, unseen as drawn
speed: 900, # how fast the pen goes, in pixels of the canvas a second
max: 8_000 # the most one drawing takes, in milliseconds; then the rest at once
]The agent's live: false lands a drawing at once. It also lands at once
with travel: false (Hakushi's or Issho's), and when no one in the room
shows the canvas, or each of them prefers reduced motion: an agent draws
for someone who watches.
What a person sees
An agent reads the graph, not the screen, and some of what a person sees
is not in the graph: how big a node is drawn, and what is on top of what.
Each browser with the canvas open says what its person sees (once a
second at most, when it changes, in their awareness), and read_canvas
gives it, of the person it reads about (of), else of the one it acts
for, else of anyone with the canvas open (seen_by):
- a node on their screen has its
boxthere,[x, y, width, height]in the canvas's coordinates; - an edge that passes under other nodes on its way has
passes_under, their ids: to the person, the line seems to end at such a node, which it does not join.
So an agent asked "why is this note connected to that one?" can say that it is not: the edge goes from the note to another card, under it. The others' browsers get none of it.
Following, and the page
A click on someone's avatar, on the canvas or in the page's people list
(Issho.Components.people/1), follows them: the canvas shows what theirs
shows, or keeps an agent's pointer on the screen. A wheel, a press or a
key on the canvas stops following. On a page with Issho's pointers
(Issho.Components.pointers/1), they draw everyone's pointer on the
canvas too, at the same point of the canvas for each person, and one
pointer goes from the page onto the canvas and back.
A canvas in a page of another room
A canvas shared in its own room, in a page of another room (a canvas embedded in a Kotoba document): its people are its room's, wherever they are. Someone reading the document sees the cursor of someone on the canvas's own page, and the other way round, and the same canvas's changes, avatars and following. The page's pointers, of the document's room, draw no one on the canvas (see Issho's guide): the canvas draws its people, except those whom Issho's pointers of its own room draw (on the canvas's own page, when it has them).
- Following someone into the canvas, from the page's people list, follows them on the canvas too; following someone from the canvas's avatars brings the canvas into view, once, when it is off the screen.
- A canvas in a page twice (the same canvas, two embeds) is one person in the room: the copy they use (their pointer, a press, the focus) says where they are, and the other copies say nothing.
- A canvas that goes while its LiveView stays (an embed deleted from the document) lets its room go: the LiveView leaves the room with its last canvas there, and the others see the person go.
See In a page.
A canvas that shows its people (its avatars) says whom it follows:
"Following Ada", with Stop, under the avatars, in their colour, and a ring
of it around the canvas. The look is Issho's, the same as its people
list's and every other block's on the page: the line carries Issho's
classes (issho-following, issho-following-stop) besides its own
(.hakushi-following, the ring .hakushi-following-ring), the canvas
carries data-ui-following while it follows, and Issho's properties, on
the page, restyle all of them at once:
:root {
--issho-following-background: #111827; /* the person's colour */
--issho-following-text: #fff;
--issho-following-ring: none; /* a ring of the person's colour */
}--hakushi-follow-color is the followed person's colour, for your own
rules; the words are the following and stopFollowing messages.
A canvas that should take no part in following, though the other blocks
of its room do, says so with issho's follow: false: its avatars are not
buttons, it shows no one followed, and its view stays its person's.
Its changes go the way of everyone's: the canvas's rules check them, and
your on_change is asked once for each. A canvas no one has open is the
document your policy defines for it (Hakushi.Issho.document/2, see
The room's document): the agent opens it from
there, with no browser at all, and people who open the canvas later see
its changes. Without it, the agent works only on a canvas someone has
open.
Give them to your app's own model, through its API, with Issho's room tools:
tools = Issho.Tools.room() ++ Hakushi.Issho.tools()
ctx = Issho.Tool.Context.new(MyApp.Issho, assistant, on_behalf_of: current_user)
Issho.Tools.schemas(tools) # the tools of a request to the model
Issho.Tools.call(tools, name, input, ctx) # each tool use in its answeror serve the same tools to MCP clients (with {:sake, "~> 0.4"}):
defmodule MyApp.MCP do
use Issho.MCP,
name: "my-app",
version: "1.0.0",
tools: Issho.Tools.room() ++ Hakushi.Issho.tools()
endSee Issho's agents guide for who an agent is, and for whom it acts.
The repository's development app serves them at /mcp. With mix dev
running and the schema open at http://localhost:4099, connect Claude Code
with claude mcp add --transport http hakushi-dev http://localhost:4099/mcp,
ask it to lay out the tables of atelier:schema, and watch.