Before we get into subscription mechanics, there's three things we need to touch
on that are very commonly used in Gall agents. The first is defining an agent's
types in a /sur
structure file, the second is mark
files, and the third is
permissions. Note the example code presented in this lesson will not yet build a
fully functioning Gall agent, we'll get to that in the next lesson.
/sur
In the previous lesson on pokes, we used a
very simple union in the vase
for incoming pokes:
=/ action !<(?(%inc %dec) vase)
A real Gall agent is likely to have a more complicated API. The most common
approach is to define a head-tagged union of all possible poke types the agent
will accept, and another for all possible updates it might send out to
subscribers. Rather than defining these types in the agent itself, you would
typically define them in a separate core saved in the /sur
directory of the
desk. The /sur
directory is the canonical location for userspace type
definitions.
With this approach, your agent can simply import the structures file and make use of its types. Additionally, if someone else wants to write an agent that interfaces with yours, they can include your structure file in their own desk to interact with your agent's API in a type-safe way.
Example
Let's look at a practical example. If we were creating a simple To-Do app, our
agent might accept a few possible action
s as pokes: Adding a new task,
deleting a task, toggling a task's "done" status, and renaming an existing task.
It might also be able to send update
s out to subscribers when these events
occur. If our agent were named %todo
, it might have the following structure in
/sur/todo.hoon
:
|%
+$ id @
+$ name @t
+$ task [=name done=?]
+$ tasks (map id task)
+$ action
$% [%add =name]
[%del =id]
[%toggle =id]
[%rename =id =name]
==
+$ update
$% [%add =id =name]
[%del =id]
[%toggle =id]
[%rename =id =name]
[%initial =tasks]
==
--
Our %todo
agent could then import this structure file with a fashep ford
rune (/-
) at the beginning of the agent like
so:
/- todo
The agent's state could be defined like:
|%
+$ versioned-state
$% state-0
==
+$ state-0 [%0 =tasks:todo]
+$ card card:agent:gall
--
Then, in its on-poke
arm, it could handle these actions in the following
manner:
++ on-poke
|= [=mark =vase]
^- (quip card _this)
|^
?> =(src.bowl our.bowl)
?+ mark (on-poke:def mark vase)
%todo-action
=^ cards state
(handle-poke !<(action:todo vase))
[cards this]
==
::
++ handle-poke
|= =action:todo
^- (quip card _state)
?- -.action
%add
:_ state(tasks (~(put by tasks) now.bowl [name.action %.n]))
:~ :* %give %fact ~[/updates] %todo-update
!>(`update:todo`[%add now.bowl name.action])
==
==
::
%del
:_ state(tasks (~(del by tasks) id.action))
:~ :* %give %fact ~[/updates] %todo-update
!>(`update:todo`action)
==
==
::
%toggle
:_ %= state
tasks %+ ~(jab by tasks)
id.action
|=(=task:todo task(done !done.task))
==
:~ :* %give %fact ~[/updates] %todo-update
!>(`update:todo`action)
==
==
::
%rename
:_ %= state
tasks %+ ~(jab by tasks)
id.action
|=(=task:todo task(name name.action))
==
:~ :* %give %fact ~[/updates] %todo-update
!>(`update:todo`action)
==
==
::
%allow
`state(friends (~(put in friends) who.action))
::
%kick
:_ state(friends (~(del in friends) who.action))
:~ [%give %kick ~[/updates] `who.action]
==
==
--
Let's break this down a bit. Firstly, our on-poke
arm includes a
barket (|^
) rune. Barket creates a
core with a $
arm that's computed immediately. We extract the vase
to the
action:todo
type and immediately pass it to the handle-poke
arm of the core
created with the barket. This handle-poke
arm tests what kind of action
it's
received by checking its head. It then updates the state, and also sends an
update to subscribers, as appropriate. Don't worry too much about the %give
card
for now - we'll cover subscriptions in the next lesson.
Notice that the handle-poke
arm produces a (quip card _state)
rather than
(quip card _this)
. The call to handle-poke
is also part of the following
expression:
=^ cards state
(handle-poke !<(action:todo vase))
[cards this]
The tisket (=^
) expression takes two
arguments: A new named noun to pin to the subject (cards
in this case), and an
existing wing of the subject to modify (state
in this case). Since
handle-poke
produces (quip card _state)
, we're saving the card
s it
produces to cards
and replacing the existing state
with its new one.
Finally, we produce [cards this]
, where this
will now contain the modified
state
. The [cards this]
is a (quip card _this)
, which our on-poke
arm is
expected to produce.
This might seem a little convoluted, but it's a common pattern we do for two
reasons. Firstly, it's not ideal to be passing around the entire this
agent
core - it's much tidier just passing around the state
, until you actually want
to return it to Gall. Secondly, It's much easier to read when the poke handling
logic is separated into its own arm. This is a fairly simple example but if your
agent is more complex, handling multiple marks and containing additional logic
before it gets to the actual contents of the vase
, structuring things this way
can be useful.
You can of course structure your on-poke
arm differently than we've done
here - we're just demonstrating a typical pattern.
mark
files
So far we've just used a %noun
mark for pokes - we haven't really delved into
what such mark
s represent, or considered writing custom ones.
Formally, marks are file types in the Clay filesystem. They correspond to mark
files in the /mar
directory of a desk. The %noun
mark, for example,
corresponds to the /mar/noun.hoon
file. Mark files define the actual hoon data
type for the file (e.g. a *
noun for the %noun
mark), but they also specify
some extra things:
- Methods for converting between the mark in question and other marks.
- Revision control functions like patching, diffing, merging, etc.
Aside from their use by Clay for storing files in the filesystem, they're also
used extensively for exchanging data with the outside world, and for exchanging
data between Gall agents. When data comes in from a remote ship, destined for a
particular Gall agent, it will be validated by the file in /mar
that
corresponds to its mark before being delivered to the agent. If the remote data
has no corresponding mark file in /mar
or it fails validation, it will crash
before it touches the agent.
A mark file is a door with exactly three arms. The door's sample is the data type the
mark will handle. For example, the sample of the %noun
mark is just non=*
,
since it handles any noun. The three arms are as follows:
grab
: Methods for converting to our mark from other marks.grow
: Methods for converting from our mark to other marks.grad
: Revision control functions.
In the context of Gall agents, you'll likely just use marks for sending and
receiving data, and not for actually storing files in Clay. Therefore, it's
unlikely you'll need to write custom revision control functions in the grad
arm. Instead, you can simply delegate grad
functions to another mark -
typically %noun
. If you want to learn more about writing such grad
functions, you can refer to the Marks Guide in
the Clay vane documentation, which is much more comprehensive, but it's not
necessary for our purposes here.
Example
Here's a very simple mark file for the action
structure we created in the
previous section:
/- todo
|_ =action:todo
++ grab
|%
++ noun action:todo
--
++ grow
|%
++ noun action
--
++ grad %noun
--
We've imported the /sur/todo.hoon
structure library from the previous section,
and we've defined the sample of the door as =action:todo
, since that's what
it will handle. Now let's consider the arms:
grab
: This handles conversion methods to our mark. It contains a core with arm names corresponding to other marks. In this case, it can only convert from anoun
mark, so that's the core's only arm. Thenoun
arm simply calls theaction
structure from our structure library. This is called "clamming" or "molding" - when some noun comes in, it gets called like(action:todo [some-noun])
- producing data of theaction
type if it nests, and crashing otherwise.grow
: This handles conversion methods from our mark. Likegrab
, it contains a core with arm names corresponding to other marks. Here we've also only added an arm for a%noun
mark. In this case,action
data will come in as the sample of our door, and thenoun
arm simply returns it, since it's already a noun (as everything is in Hoon).grad
: This is the revision control arm, and as you can see we've simply delegated it to the%noun
mark.
This mark file could be saved as /mar/todo/action.hoon
, and then the on-poke
arm in the previous example could test for it instead of %noun
like so:
++ on-poke
|= [=mark =vase]
|^ ^- (quip card _this)
?+ mark (on-poke:def mark vase)
%todo-action
...
Note how %todo-action
will be resolved to /mar/todo/action.hoon
- the hyphen
will be interpreted as /
if there's not already a /mar/todo-action.hoon
.
This simple mark file isn't all that useful. Typically, you'd add json
arms
to grow
and grab
, which allow your data to be converted to and from JSON,
and therefore allow your agent to communicate with a web front-end. Front-ends,
JSON, and Eyre's APIs which facilitate such communications will be covered in
the separate Full-Stack Walkthrough,
which you might like to work through after completing this guide. For now
though, it's still useful to use marks and understand how they work.
One further note on marks - while data from remote ships must have a matching
mark file in /mar
, it's possible to exchange data between local agents with
"fake" marks - ones that don't exist in /mar
. Your on-poke
arm could, for
example, use a made-up mark like %foobar
for actions initiated locally. This
is because marks come into play only at validation boundries, none of which are
crossed when doing local agent-to-agent communications.
Permissions
In example agents so far, we haven't bothered to check where events such as pokes are actually coming from - our example agents would accept data from anywhere, including random foreign ships. We'll now have a look at how to handle such permission checks.
Back in lesson 2 we discussed the
bowl. The bowl
includes a couple of useful
fields: our
and src
. The our
field just contains the @p
of the local
ship. The src
field contains the @p
of the ship from which the event
originated, and is updated for every new event.
When messages come in over Ames from other ships on the network, they're
encrypted with our ship's public keys and signed by the ship which sent them.
The Ames vane decrypts and verifies the messages using keys in the Jael vane,
which are obtained from the Azimuth Ethereum contract and Layer 2 data where Urbit ID ownership
and keys are recorded. This means the originating @p
of all messages are
cryptographically validated before being passed on to Gall, so the @p
specified in the src
field of the bowl
can be trusted to be correct, which
makes checking permissions very simple.
You're free to use whatever logic you want for this, but the most common way is
to use wutgar (?>
) and
wutgal (?<
) runes, which are
respectively True and False assertions that crash if they don't evaluate to the
expected truth value. To only allow messages from the local ship, you can just
do the following in the relevant agent arm:
?> =(src.bowl our.bowl)
A common permission is to allow messages from the local ship, as well as all of
its moons, which can be done with the team:title
standard library function:
?> (team:title our.bowl src.bowl)
If we want to only allow messages from a particular set of ships, we could, for
example, have a (set @p)
in our agent's state called allowed
. Then, we can
use the has:in
set function to check:
?> (~(has in allowed) src.bowl)
If we wanted to check a ship was allowed in a particular group in the Groups
app, we could scry our ship's %group-store
agent and compare:
?> .^(? %gx /(scot %p our.bowl)/group-store/(scot %da now.bowl)/groups/ship/~bitbet-bolbel/urbit-community/join/(scot %p src.bowl)/noun)
There are many ways to handle permissions, it all depends on your particular use case.
Summary
Type definitions:
- An agent's type definitions live in the
/sur
directory of a desk. - The
/sur
file is a core, typically containing a number of lusbuc (+$
) arms. /sur
files are imported with the fashep (/-
) Ford rune at the beginning of a file.- Agent API types, for pokes and updates to subscribers, are commonly defined as
head-tagged unions such as
[%foo bar=baz]
.
Mark files:
- Mark files live in the
/mar
directory of a desk. - A mark like
%foo
corresponds to a file in/mar
like/mar/foo.hoon
- Marks are file types in Clay, but are also used for passing data between agents as well as for external data generally.
- A mark file is a door with a sample of the data type it handles and exactly three
arms:
grab
,grow
andgrad
. grab
andgrow
each contain a core with arm names corresponding to other marks.grab
andgrow
define functions for converting to and from our mark, respectively.grad
defines revision control functions for Clay, but you'd typically just delegate such functions to the%noun
mark.- Incoming data from remote ships will have their marks validated by the
corresponding mark file in
/mar
. - Messages passed between agents on a local ship don't necessarily need mark
files in
/mar
. - Mark files are most commonly used for converting an agent's native types to JSON, in order to interact with a web front-end.
Permissions:
- The source of incoming messages from remote ships are cryptographically
validated by Ames and provided to Gall, which then populates the
src
field of thebowl
with the@p
. - Permissions are most commonly enforced with wutgar (
?>
) and wutgal (?<
) assertions in the relevant agent arms. - Messages can be restricted to the local ship with
?> =(src.bowl our.bowl)
or to its moons as well with?> (team:title our.bowl src.bowl)
. - There are many other ways to handle permissions, it just depends on the needs of the particular agent.
Exercises
- Have a quick look at the tisket documentation.
- Try writing a mark file for the
update:todo
type, in a similar fashion to theaction:todo
one in the mark file section. You can compare yours to the one we'll use in the next lesson.