Unseenet Working Group A. Lorensen
UN-RFC: 0046 October 2026
Category: Standards Track
Status: Draft
Implemented: unseenet 0.4.0, unseenet 0.6.0
Updates: none
Unseenet: The Local Client API
Abstract¶
A client keeps its vault, its keys, Tor and postage in one program. A front end - a graphical window, a page in a browser on the same machine, an Android app around the same core, a screen-reader client, a script - should not have to implement any of that again, and should never hold a key. This document defines a small HTTP and WebSocket API that such a program offers to its own front end on the same computer: unlock, the groups and their posts, writing, people, the groups you run, your names and profile, and an event stream; and the same for a bot or gateway that runs without a person. It also defines how the API keeps everything else on the machine out.
1. Motivation¶
unseenet-gui was built this way from the start: its page never sees a
key, and everything security-relevant happens in Python (UN-RFC 0007,
0003, 0006). Other front ends need the same, and the reference
implementation now lives in the core (unseenet.client.localapi), behind
Session and ClientRuntime. Writing the API down lets anyone build a
front end against it, in any language, without reading the GUI's code.
The API is local by design. It is not a way to reach a client from another computer; that is what nodes and web entrances (UN-RFC 0043) are for.
2. Terms¶
Runtime
: The program that holds the unlocked vault and talks to the network:
in the reference implementation, Session with ClientRuntime.
Front end : The program a person uses, talking to the runtime through this API.
Job : Something the runtime does on the front end's behalf that takes time, such as sending a post (postage, and for a new key ripening, UN-RFC 0025).
3. Security¶
A runtime offering this API:
- MUST listen on
127.0.0.1(or::1) only, on a port chosen at random when it starts. - MUST make a new random token of at least 32 octets each time it
starts, and refuse every request without it (
401). The token is given in the headerX-Unseenet-Token, or, for the WebSocket, as a subprotocolunseenet-token.<token>next tounseenet, since a browser cannot set headers on a WebSocket. It MUST be compared in constant time. - MUST refuse (
403) a request whoseHostis not its own address and port (127.0.0.1:<port>orlocalhost:<port>); this stops DNS rebinding, where a web page on the Internet makes a browser talk to a local port under another name. - MUST refuse (
403) a request with anOriginother than its own, and MUST NOT send any CORS header: no other page may read its answers. - SHOULD serve any page it offers with a strict Content-Security-Policy
(
default-src 'none'with only'self'for scripts, styles and connections), so that a post can never load anything from outside. - MUST NOT send a private key, a group key or the passphrase to the front end, in any answer or event. The passphrase goes in only, with unlock.
The token is what keeps other local users and programs out; the Host
and Origin checks are what keep web pages out. Both are needed.
3.1 Handing the Address to the Front End¶
Whatever starts the runtime gives the address and the token to its front
end out of band, in one of two ways (the reference implementation,
unseenet serve, does both for a vault and the first for a service):
- on standard output, as one line:
UNSEENET-API 1 http://127.0.0.1:<port>/ <token>; or - in a file only the user can read (mode
0600; on Windows, in a folder only the user can read, as the vault is), next to the vault, named<vault file>.api, holding that same line, removed when the runtime stops.
A front end opened as a page by the runtime itself MAY get the token in
the URL fragment (#t=<token>), which browsers do not send to servers.
3.2 Service Mode¶
A program that runs unattended - a bot, a gateway, a helper behind a
node - uses the same API with its own runtime (unseenet serve). It has
no vault to unlock and no person to set up: it starts with a key file
unseenet serve --key-file <file> [--node <address>] [--state <file>]
and is unlocked from the start (setup false, unlocked true, section
4.1). Its state file keeps its ripening chain (UN-RFC 0025) and what it
has read, so a restart neither ripens again nor answers twice.
POST /api/unlock and /api/setup answer 400 in service mode.
Everything else is the same, and section 4.9 adds what only a program needs. The security rules of section 3 apply unchanged: a service's front end is the program on the same machine that drives it.
4. Requests and Answers¶
All bodies are JSON (application/json; charset=utf-8). A group ID in a
path is percent-encoded. Errors are {"error": "<text for people>"} with:
| Status | Meaning |
|---|---|
| 400 | The request is wrong (a missing field, nothing to send) |
| 401 | No or a wrong token |
| 403 | Wrong Host or Origin |
| 404 | No such group |
| 409 | Locked: unlock first (or set up, section 4.2) |
| 502 | The node or Tor could not be reached |
Messages and groups have the core's dict form, version 1
(Message.to_dict(), GroupInfo.to_dict(), field v). A front end MUST
check v and refuse a version it does not know rather than draw wrong.
4.1 State¶
GET /api/state, also while locked:
{"api": 1, "unlocked": false, "setup": false, "connected": false,
"status": "Locked", "identity": null, "node": null, "inbox_ready": false,
"ripening": null}
Unlocked: identity is {"key": "ed25519:...", "display": "...", "label",
"words_written", "words_reminder"} (the last two: section 4.18),
node the default node, node_label how it is shown (section 4.17),
nodes your node addresses and node_names each one's {"name",
"source", "label"}, and ripening {"level": <0-4 or null>, "t":
<iterations>, "next_at": <iterations or null>, "published": <the level
nodes know, or null>, "available": <true if this runtime can ripen>,
"paused": <true while paused>, "target": <the level it is ripened to
here>, "chosen": <3 or 4 when the user chose, else null>, "seconds_left":
<about how long to the target at this device's speed, or null>, "speed":
<iterations a second here, or null>} (UN-RFC 0025). setup is true while the
identity or the node is still missing.
status is a line for people. status_idle is true when it only says
that a node was reached ("Connected to ...", the default node or one a
group is read from): a front end that shows the node elsewhere MAY leave
such a line out, and show status again when status_idle is false (Tor
starting, offline, a post being sent). Whether a node is reached right
now is the node-state event's (section 5), per node.
POST /api/ripening {"paused": true | false} pauses or resumes ripening
in the background (UN-RFC 0025 section 7), for example on a warm phone or
a laptop on battery, and answers the state's ripening. A pause lasts
until it is resumed, also over a restart: the runtime keeps it with the
key's ripening in the vault, on this device only - it is not part of your
state in the network (UN-RFC 0044), so pausing on a phone does not pause
another device. It stops only the background ripening: a post that needs
a riper key still ripens what it needs.
POST /api/ripening {"to": 3 | 4} chooses the level the key is ripened
to on this device (UN-RFC 0025 section 7), kept like the pause: 3 is the
default, 4 takes weeks of one core while the runtime runs and then pays 2
bits less postage. Raised, ripening starts again unless paused; lowered,
it stops after the segment it is on. It answers the state's ripening,
and every front end gets the event ripening-target (target). A front
end SHOULD say what level 4 costs and gives, with seconds_left, before
choosing it. {"paused": ..., "to": ...} does both. A service (section
3.2) ripens to UNSEENET_RIPEN_TO (0-4) when it is set, else to 3.
4.2 Unlocking and Setting Up¶
POST /api/unlock{"passphrase": "..."}: answers the state. A wrong passphrase is400. Instead of the passphrase,{"device": "<id>", "secret": "<b64>"}opens the vault on a device set up below.POST /api/setup{"display": "...", "node": "..."}: the first start of a new vault. It makes the identity and adds the node, then the runtime starts (UN-RFC 0025: a new key ripens in the background).POST /api/passphrase{"old": "...", "new": "..."}: a new passphrase for this vault file, answering{"ok": true}. A wrongoldor anewshorter than 4 characters is400; if the file cannot be written it is500and the old passphrase still works. Copies of the vault elsewhere keep the old one. Not for a service (section 3.2). It also removes the device unlocks below, which held the old key.- A PIN on a device (UN-RFC 0050 section 5). The vault key can be wrapped
for a device by a secret that the device's secure hardware releases
only after its PIN or its own unlock, and limits guesses for. In a
browser this is a passkey's WebAuthn PRF output. The wrapped key is kept
beside the vault (
<vault>.unlock), is never copied with it and opens nothing without the device. The passphrase keeps working everywhere. GET /api/device-unlock:{"possible", "devices": [{"id", "label", "created"}]}. This also works while locked, so a page knows which passkey to ask for.possibleis false for a service.POST /api/device-unlock{"passphrase", "id", "secret", "label"}:idis the credential's ID andsecretis b64 of at least 32 octets. It needs the vault's passphrase, so an unlocked, unattended client cannot let another device in. A device is at most 8.POST /api/device-unlock/remove{"id"}or{"all": true}.
4.3 Groups¶
GET /api/groups:
{"groups": [<GroupInfo dict>, ...],
"people": [{"id": "contact:<key>", "key": "...", "name": "...", "contact": true,
"unread": 0}, ...],
"contacts": [{"id": "contact:<key>", "key": "...", "name": "...", "closed": false}, ...],
"inbox": {"id": "inbox@<fp>", "unread": 0},
"hierarchies": [{"name": "dk", "tree": <folder>}, ...],
"for_you": {"id": "for-you:", "replies": 0, "mentions": 0},
"timeline": {"id": "timeline:", "feeds": 0},
"my_feed": {"id": "feed@<fp>", "started": false, "unread": 0},
"my_site": {"id": "site@<fp>", "started": false, "unread": 0},
"moderation": 0, "name_requests": 0}
groups are the groups the identity follows, in its order
(Session.subscribed_groups()), with their counts. people are its chats
(UN-RFC 0028 section 6.2), without the ones you closed (section 4.6);
contacts are all your contacts, with whether their chat is closed;
inbox.id is null until the inbox is set up,
moderation counts the posts waiting for you to approve (section 4.7) and
name_requests the people asking for a name under yours (section 4.8).
Every group here and elsewhere also has shown: its name as people should
see it, without the fingerprint only where the hierarchy lists the name
(UN-RFC 0018). A front end shows shown, not label.
hierarchies lays the groups out as every client of this project does: a
folder is {"label", "path", "own", "groups": [<group ID>, ...],
"children": [<folder>, ...]}, names under alt. without it and a shared
part of names as a folder.
Besides real group IDs, three pseudo IDs work
wherever a group ID does: contact:<key> (the chat with one person),
for-you: (replies and mentions from all groups) and timeline: (the
entries of every group you have in the timeline, section 4.14).
timeline is {"id": "timeline:", "feeds": <how many groups are in it>},
and every group in groups has in_timeline and timeline_possible
(UN-RFC 0034 section 6.2), so a front end can mark them in its list and
offer the choice there.
people_followed is what you follow because of a person (#706):
[{"fingerprint", "key", "label", "groups": {"feed": <Group ID>, "site": ...}}, ...],
other people's personal groups (UN-RFC 0029 section 2.1) by the person they belong to,
sorted by label; hierarchies leaves them out, so a front end shows each person once,
with their feed and site together, rather than hierarchies called feed and site.
my_feed is your personal feed (UN-RFC 0034 section 5) and my_site
your personal site (UN-RFC 0035 section 7): a front end lists each at the
top as "My feed" and "My site" once started, and then hierarchies
leaves it out; before, it offers to start it (sections 4.14, 4.15).
GET /api/groups/<id>:
{"group": <GroupInfo dict>,
"rows": [{"message": <Message dict>, "depth": 0, "read": true, "for_me": null,
"who": "...", "who_key": "...", "contact": false,
"mine": false, "bot": false, "may_edit": false, "may_withdraw": false,
"tree": [...], "groups": ["#name@fp", ...], "notice": null}, ...]}
rows are in the order to show them (Session.threads()): threads
newest first for a group read as threads, every line in order for one
read as a chat. ?limit=N asks for the newest N threads (or feed entries),
each with all its replies, or the newest N lines of a chat; the group's
more is true when older ones were left out, and a front end asks again
with a larger limit to show them. Without limit every row comes, as
before; a front end showing large groups SHOULD ask with one. for_me is "reply", "mention" or null. who is how
the runtime names the author (a contact's name, a verified name, UN-RFC
0019), so that every front end names people alike. tree is the text
rendered for showing (UN-RFC 0031): Markdown or plain by the post's
Content-Type, as nodes {"t": "p" | "text" | "strong" | ..., "c": [...],
"v": "..."} in which HTML is always text. A front end MUST draw the tree,
or the plain text, and MUST NOT put a post's text into a page as HTML.
A post with a read-more line (UN-RFC 0031 section 3.2.1) has the block
{"t": "more"} in its place, at the top level of the tree: a front end
that cuts the post shows what comes before it, and draws nothing for it
when the post is shown whole. A front end that does not know the block
draws nothing for it.
bot is true when the author's profile says it posts automatically
(UN-RFC 0030 section 6): show a mark. identicon is the author's identicon
(section 4.11). groups are the #group references in it; notice is set for a mention
notice (UN-RFC 0032): {"group", "id", "label", "ref", "followed"}. The group adds
may_post, and for a real group timeline - {"on", "possible",
"default"}: whether it is in your timeline, whether it can be, and what
it is without your choice (UN-RFC 0034 section 6.2) - charter, charter_type, charter_tree
(the group's charter, UN-RFC 0014; not your own rules, section 4.20), portals,
portal_warning, write, files and node_files (section 4.10), and for its owner or a moderator roles (section 4.7):
{"moderators", "posters", "banned", "moderates", "maintainer"} and, for a
closed group's owner, members; maintainer is true when you maintain a
hierarchy the group is in, so you may block people there (section 4.6).
write says whether you may write here, before you write:
{"post": <permission>, "chat": <permission>}, and in a real group each
row has reply, whether you may answer that post. A permission is
{"allowed": true | false, "reason": "<for people>", "code": "ok" |
"own_inbox" | "kind" | "banned" | "blocked" | "not_member" | "channel" |
"channel_thread" | "comments_closed"}, from the cached manifest: the kinds
a group takes (UN-RFC 0028), bans and blocks (UN-RFC 0004, 0018),
posters and members, and channel comments (UN-RFC 0030). A front end
SHOULD make what is not allowed unavailable up front and show reason;
the node still has the last word.
Reactions (UN-RFC 0048). A group's head has reactions:
{"offered": ["👍", ...], "any": false, "setting": "default"}, the emoji
the group takes in the order to offer them (with any, the default set
first and any other emoji besides; offered is empty when the group takes
none) and the owner's choice as section 4.7 takes it. In a real
group and in the timeline, each row has reactions, [{"emoji": "👍",
"count": 3, "mine": true}, ...] in the order they were first given, one
per key, react, a permission whose further codes are "private",
"target" and "no_reactions", and react_offered, the set of the row's
own group as in the head (the timeline mixes groups). Reactions are never
rows themselves.
POST /api/groups/<id>/read{"ids": [<Message-ID>, ...]}: at most 500. With{"all": true}instead, the whole group is marked read - a newsreader's "catch up" - and the answer says how many were unread ({"ok": true, "marked": n}).POST /api/groups/<id>/view{"view": "threads" | "chat" | "feed" | "pages" | null}: null goes back to the owner's choice (UN-RFC 0008 section 3.1, UN-RFC 0034 section 3.1);pagesonly for a site (section 4.15).POST /api/groups/<id>/timeline{"on": true | false | null}: put a group you follow into your timeline, take it out, or null for its default (UN-RFC 0034 section 6.2). Answers{"ok": true, "on": <now>}; a group that cannot be in it (chat, inbox) is refused. The view you read a group in is a separate setting.POST /api/subscribe{"id": "<group ID>", "on": true}.GET /api/browse?pattern=<wildmat>:{"groups": [<GroupInfo dict>, ...]}, what the node lists.GET /api/known-nodes:{"nodes": [{"address", "name", "description", "operator"}, ...]}(UN-RFC 0010), for choosing a node. It answers as soon as the vault is unlocked, also whilesetupis true, since that is where a new user chooses one.GET /api/group-ref?ref=<#name@fp or #name>:{"groups": [...]}, what a#groupreference in a post can mean, each with awarningfor people where the name is not the hierarchy's (UN-RFC 0018).GET /api/about: what Unseenet is, in plain words, also while locked.POST /api/refresh: fetch every group now.
4.4 Writing¶
POST /api/groups/<id>/post{"kind": "post" | "chat", "subject": "...", "body": "...", "references": [...]}answers{"job": "<job ID>"}at once. A post needs a subject; a chat line has none (UN-RFC 0028). A post gets your signature unless"signature": false;"notify"true or false overrides your setting for mention notices (UN-RFC 0032). Tocontact:<key>it is a private post.POST /api/private{"key": "ed25519:...", "kind": "post" | "chat", "subject": "...", "body": "...", "reply_to": <Message-ID or null>}answers{"job": "<job ID>"}.
A post to a group, a chat line, and a private post or chat may carry
files: "files": [{"name": "...", "data": "<base64>", "alt": "..."},
...], at most four (section 4.10). With files the body may be empty. In
closed groups and private posts the runtime encrypts them (UN-RFC 0027
section 3.3); to an inbox that takes no files the job fails, saying so.
An edit (section 4.5) keeps the files of the earlier version.
Its progress comes as job events (section 5). The runtime asks about
portals (UN-RFC 0023 section 3.5) before it sends: a group with a portal
answers 400 with the warning until the post carries
"publish_outside_tor": true.
POST /api/markup {"text": "...", "kind": "post" | "chat", "markdown":
true | false} answers {"tree": [...], "signature": "..." or null}: the
tree the text will have once posted, the same as messages carry (section
4.3), for a Write / Preview toggle in compose windows, so every client
previews exactly what readers will see. Without markdown the text is
taken as your posts go (your Markdown setting); a post's signature, after
"-- ", comes apart as when it is shown. Nothing is sent; at most 256 KiB.
GET /api/emoji, also while locked, is what a front end needs for an
emoji picker and for completing :ta while typing (UN-RFC 0031 section
5.2): {"table": {"<shortcode>": "<glyph>", ...}, "categories": [{"name",
"codes": [...]}, ...], "names": {"<shortcode>": "<search words>", ...}}.
categories lists each emoji once, by its first shortcode; names has
its name, tags and other shortcodes, for searching. It does not change
while the runtime runs, so a front end loads it once. A picker puts the
:shortcode: in the text, not the glyph, so the text stays readable in
every client. The order to suggest in is that of the core's
find_emoji(): shortcodes that start with what was typed (shortest
first), then those containing it, then by a word of the name.
4.5 Your Own Posts¶
POST /api/groups/<id>/edit{"id": "<Message-ID>", "subject": "...", "body": "..."}: a new version of your own post or chat line (UN-RFC 0042); answers{"job": "<id>"}. It takestagsandlanguagesaspostdoes (UN-RFC 0052); left out, the earlier version's are kept - its tags and warnings apart from the#wordsin its text, which come from the new text. A message you may edit carriesedit_tags, the tags an edit box starts with. An edit of a thread's first post may carrycomment_daysto shorten its time for comments, never to lengthen it (section 4.24).POST /api/groups/<id>/withdraw{"id": "<Message-ID>"}: your own post, or one in a group you own or moderate (UN-RFC 0004 section 7).POST /api/groups/<id>/react{"id": "<Message-ID>", "emoji": "👍"}: react to a post, comment or chat line (UN-RFC 0048), as a job ({"job": "<id>"}); the emoji you already gave takes it back, another one changes it. With"who": trueit answers{"who": ["<label>", ...]}, who gave that emoji, instead.POST /api/groups/<id>/mentioned{"subject": "...", "body": "..."}: who a post would tell (UN-RFC 0032), before it is sent:{"may": true, "names": [...], "max": 10}.
4.6 People and Safety¶
POST /api/contacts{"key": "...", "name": "..."}adds or renames a contact,{"key": "...", "remove": true}removes one.POST /api/chats/close{"key": "...", "on": true}takes a chat with a person offpeopleinGET /api/groups("on": falseputs it back). It is kept in the state (UN-RFC 0044 section 4.2,closed_chats), and the chat comes back by itself with the next chat line from or to them; sending one opens it too.GET /api/ignorelists the keys you ignore ({"ignored": [{"key", "label"}, ...]});POST /api/ignore{"key": "...", "label": "...", "on": true}ignores one or stops.GET /api/groups/<id>/report?id=<Message-ID>lists who a post can be reported to (owner, moderators, the hierarchy's maintainer, the node's operator, and spam report issuers, UN-RFC 0045) as{"targets": [{"i": 0, "who": "..."}, ...], "reasons": [{"id", "label"}, ...]}-spam,illegal,threats,other, as every client offers them;POSTit{"id": "...", "targets": [<index>, ...], "reason": "...", "note": "...", "files": [...], "anonymous": false}answers at once{"job", "targets"}: each receiver's sealed copy pays postage as an unripe key (UN-RFC 0025 section 3.5), which can take minutes, so it is a job like a post (section 5), andsentsays when every receiver has it. The note is Markdown (UN-RFC 0031) andfilesare as on a post (section 4.10) - a screenshot, a document - sent encrypted to each receiver (UN-RFC 0027 section 3.3). The report arrives as Markdown with the parameterreport=postin its Content-Type, its group, Message-ID, author key and reason as lines of their own.POST /api/groups/<id>/block{"key": "...", "reason": "..."}: a hierarchy maintainer's block (UN-RFC 0018 section 5.4); answers{"hierarchy": "<name>"}.
4.7 Groups You Run¶
Each answers {"ok": true} unless said otherwise, and 400 when you may
not (you do not own or moderate the group).
POST /api/groups{"name": "...", "type": "open" | "moderated" | "channel" | "closed", "title": "...", "description": "...", "view": "threads" | "chat" | "feed" | "pages", "kinds": "both" | "post" | "chat", "fast": false, "comments": "open" | "none", "files": "none" | "pictures" | "pictures+pdf", "charter": "...", "charter_type": "...", "comment_days": n}creates a group you own; answers{"id": "<group ID>"}.filesis what files it takes (UN-RFC 0027 section 5.1): none (the default), pictures, or pictures and PDFs, up to 4 MiB each.pagesmakes a site and needs"type": "channel"(UN-RFC 0035 section 3);commentsis for channels (UN-RFC 0030), and a channel shown asfeedtakes comments unless it saysnone;comment_days, days during which a thread takes comments or replies, is for any of these types (UN-RFC 0030 section 3.3);fastis for chat groups only.POST /api/groups/<id>/settingswith the manifest fields to change (title,description,charter,charter_type,view,kindsas for creating,min_pow,low_latency,retention_days,comments,comment_days,filesas for creating,reactions:"default","none","any"or a list of up to 12 emoji, UN-RFC 0048 section 4,index:"yes"or"no"for a site, UN-RFC 0035 section 3): a new manifest (UN-RFC 0004, 0014, 0030). Any other field is400.GET /api/help/group-settings:{"intro", "settings": [{"key", "label", "what", "affects", "later"}]}- per setting, in English, what it does, whom it affects (readers, writers, nodes) and what changing it later does, for a help panel beside a client's Edit group;keyis the field above. The same text in every client; it needs no unlocked vault.- A group's own emoji (UN-RFC 0056):
GET /api/groups/<id>hasemoji({name: picture}, as last read) andemoji_possible.GET /api/groups/<id>/emojireads them anew from the node, without following the emoji group, and answers{"emoji", "possible", "max", "warning"}. The owner usesPOST /api/groups/<id>/emojiwith one of: {"preview": {"data", "alt"}}: the picture as it would be published, with nothing published;{"add": {"name", "data", "alt"}}:datais b64 of any picture, made 32 by 32, at most 1 KiB and without metadata here;{"alt": {"name", "alt"}},{"rename": {"from", "to"}}or{"remove": "<name>"}.
Each change publishes the whole set and answers as GET. A standard
name, a 17th emoji or a group that cannot have them is 400. Clients
show warning before the first emoji is published.
- POST /api/groups/<id>/remove {"confirm": "remove", "reason": "..."}:
removes the group from the whole network (UN-RFC 0013). Final.
- POST /api/groups/<id>/invite {"key": "..."} and
POST /api/groups/<id>/members/remove {"key": "..."} (closed groups;
removing answers {"epoch": <n>}, UN-RFC 0004 section 6).
- POST /api/groups/<id>/role {"role": "moderators" | "posters", "key": "...", "on": true}.
- POST /api/groups/<id>/ban {"key": "...", "on": true, "reason": "..."}.
- GET /api/moderation lists posts waiting for you to approve (UN-RFC 0004
section 5) as {"waiting": [{"id", "group", "subject", "who", "key",
"body", "tree", ...}, ...]}; POST it {"id": "...", "approve": true, "reason": "..."}.
4.8 Who You Are¶
GET /api/profile?key=<key>(yours withoutkey): a public profile (UN-RFC 0022) ornull, as{"key", "profile", "about_tree", "label", "mine", "hidden", "link_kinds"}(andshared, section 4.16).hiddenis true when you hid this key's profile (UN-RFC 0022 section 9.2); then it is not even fetched. Before fetching someone's, the runtime reads the profiles of the authors it has together with it, so a node cannot tell whose you open (UN-RFC 0022 section 13). Yours addspublished(whether one is published yet) andwarning, the text to show before the first is published (section 9.1), the same in every client, andpicture(yours as published, section 4.11, or null) withpicture_warning, the text to show before the first.POST /api/profilewith a profile object publishes yours,nullremoves it;{"edit": {"about": "...", "links": [{"kind", "value", "label"}], "lang": ["da", ...], "markdown": true}}builds the object from a form's fields and keeps the bot mark you have. It answers asGET, withpublished. A program's profile says"bot": true(UN-RFC 0030 section 6).POST /api/profile/hide{"key": "...", "hidden": true | false}: hide or show someone's profile, for you alone; answers{"key", "hidden"}.GET /api/nameslists your names, waiting claims and the names you give under yours (UN-RFC 0019);POST /api/names{"action": "claim" | "renew" | "give" | "revoke" | "approve" | "refuse", ...}answers{"text": "<what happened, for people>"}.GET /api/settingsandPOST /api/settingswith any of:signature(a template),markdown(true or false, UN-RFC 0031),notices({"send": true, "receive": true}, UN-RFC 0032),private_min_ripe(a level or null, UN-RFC 0025 section 3.5),show_blocked(true or false: show posts a hierarchy blocked, UN-RFC 0018),hide_bots(true or false: leave out posts by keys whose profile saysbot, UN-RFC 0030 section 6),pictures(true or false: false shows identicons only, section 4.11),state_local(true or false: keep your state on this device only, or in the network, UN-RFC 0044 section 7.4; turning it on removes what is in the network). Both answer all settings, withsignature_preview(the template filled in). An unknown setting is400, and then nothing is changed.-
GET /api/settings/clients/<client>answers{"client", "settings": {...}}: the settings one kind of client keeps for itself (gui,tui, ...), kept with your state so they follow you from device to device (UN-RFC 0044 section 4.2) - a theme, a font size.POSTit{"<key>": value, ...}to change them,nullremoving one; it answers asGET. Client names and keys are lowercase letters, digits and-(at most 32); values are strings (at most 256 characters), numbers, true or false. A front end MAY keep a copy in the browser for its first paint, but these are what count. -
GET /api/notifications: what new has landed in For you since any client last asked - private chats and posts to you, replies and mentions; not your own, not reactions, not what was old on arrival (UN-RFC 0049 section 5.2).{"settings", "count", "items": [{"id", "group", "why": "private" | "reply" | "mention", "chat", "who", "text"}]};whoandtextonly whensettings.contentis true. Asking marks them as told. The first time ever it answers none and notes what is unread then.settingsis the reader's choice, also inGET /api/settingsasnotificationsand changed there with{"notifications": {"on": true | false | null, "content": bool}}(onnull: ask the first time); it is kept with the state (UN-RFC 0044), so it is the same in every client. Front ends tell only while they run: there is no push. -
POST /api/export{"path", "private": false, "passphrase"}: write an export of your data as UN-RFC 0049 section 7 says - your own articles as signed, their files, the sites you write in, your profile and picture, your state and anindex.html; never keys.pathdefaults to Downloads. Withprivatethe private posts and chats are in it too and the whole file is encrypted withpassphrase(UN-RFC 0007), named….zip.sealed. Answers{"path", "articles", "files", "sites", "private", "encrypted", "missing", "note"};notesays it holds only what the nodes still carry and the client kept - show it.
4.9 Articles from a Program¶
For a service (section 3.2), such as a gateway, that builds what it posts:
POST /api/articles{"group": "...", "kind": "post" | "chat", "subject": "...", "body": "...", "from": "...", "references": [...], "content_type": "...", "headers": [["X-Gateway", "usenet"], ...]}answers{"job": "<id>"}. The runtime builds, signs with its key, adds postage andKey-Ripening, and posts it (Session.post_articlewithsign_with).headersmay only add headers this document's runtime may not set itself: names starting withX-, or those another UN-RFC gives a program (Gateway-*, UN-RFC 0023). Anything else is400.POST /api/groups/<id>/manifestwith a whole manifest object posts a new version of a group the key owns (a gatewayed group, a bot's channel), for what section 4.7 does not cover.type,owner,nameandlocalcannot change; the runtime setsserial.
4.10 Files on Posts¶
Files are blobs (UN-RFC 0027). A file from the network is hostile input, so the runtime does all the work with it and the front end gets only what the runtime has checked and, for a picture, saved again.
A Message dict lists the files a post carries in attachments:
[{"blob": "blob1:...", "type": "image/jpeg", "size": <octets>, "name":
"...", "alt": "...", "dim": [<width>, <height>] or null}, ...], in the
order to show them. name and alt are text the author wrote (UN-RFC
0027 section 4.2): show them as text. A group's head (section 4.3) has
files, what the group takes - {"max_size": <octets>, "types": [...]},
types empty for any type, or null when it takes none (UN-RFC 0027
section 5.1) - and node_files: true when the node the group is read
from carries files, false when it does not, null when the runtime is not
connected to it yet. A front end SHOULD say plainly when a group takes
files but its node carries none, rather than show posts whose pictures
never come.
GET /api/groups/<id>/files/<blob-id> answers a picture a post in that
group carries, made safe to show:
{"type": "image/jpeg", "width": 64, "height": 48, "frames": 1,
"data": "data:image/jpeg;base64,..."}
The runtime fetches the blob from the group's node, checks it against
its blob ID and the size the post names, determines the type from the
content (it MUST be the declared one), and decodes and saves it again in
a separate process with as few rights as the platform allows, refusing
more than 50 megapixels or 1000 frames (UN-RFC 0027 section 9.2).
data is that new copy, never the octets from the network. Only
image/jpeg, image/png, image/gif and image/webp are shown; SVG
and PDF are never decoded. A picture by a key you ignore is answered
403 unless the request adds ?asked=1 - the person chose to see it.
With ?save=1 it answers the file itself, checked against its blob ID
but not decoded, to save: {"name": "...", "type": "...", "size":
<octets>, "data": "<base64>"}. A front end MUST NOT open it in another
program without the person asking each time, and MUST warn first that
such a program runs outside Unseenet and may go to the internet without
Tor (UN-RFC 0027 section 9.2).
Errors carry why beside error:
| Status | why |
Meaning |
|---|---|---|
| 404 | (none) | No post here that this runtime has read carries it |
| 404 | none |
The group's node carries no files |
| 404 | gone |
The node does not have it (any more) |
| 503 | later |
The node is still fetching it from a peer: try again |
| 502 | wrong |
The node sent another file than the post names |
| 403 | ignored |
By a key you ignore; ?asked=1 to see it anyway |
| 415 | not_shown |
Not a picture this API shows (save it instead) |
A post with files (section 4.4) sends each file as {"name", "data",
"alt"} with data base64. The runtime prepares them as every client of
this project does: the type from the content, a picture saved again
without its metadata (GPS position, time, camera, owner), its size in
pixels in dim, and alt in the header. It checks them against the
group before any postage is computed and answers 400 with the reason
for people when the group takes no files, not of this type or size, or
more than four or 8 MiB together. A request may be at most 12 MiB.
4.11 Pictures of People¶
Every identity has an identicon and may have a picture (UN-RFC 0022 section 8). The runtime gives both ready to draw, and the page never gets a picture the runtime has not checked.
An identicon is {"hue": <degrees>, "cells": [[true, false, ...], ...],
"colour": "#rrggbb"}: five rows of five cells, filled cells in the
colour (saturation 55 %, lightness 50 %; a front end MAY change the
lightness for its theme but MUST keep the hue). It is shown together
with the short form of the fingerprint, never instead of it (UN-RFC 0022
section 8.4). Each row of a group (section 4.3) has the author's.
GET /api/pictures?keys=<key>,<key>,... (at most 255) answers
{"pictures": {"<key>": {"identicon": {...},
"picture": {"type": "image/webp", "px": 96, "alt": "...",
"data": "data:image/webp;base64,..."} or null}}}
The runtime reads the avatar groups of these keys together with those of
the other authors it has, so a node cannot tell whose pictures are
looked at (UN-RFC 0022 section 13), and keeps them for a day. It decodes
each picture and saves it again in a separate process (UN-RFC 0027
section 9.2) and treats it as absent unless it is the square, still
picture of px pixels it says it is (UN-RFC 0022 section 8.1). A key
whose profile you hid, a key you ignore, or every key while the setting
pictures is false, gets null: show its identicon. alt is text the
owner wrote: show it as text. A picture is shown next to the names and
the short form, never instead of them and never as part of a post.
POST /api/profile/picture sets your own:
{"data": "<base64>", "alt": "...", "preview": true}makes the picture from a file the person chose - the middle square, at most 128 pixels and 8 KiB, saved again without metadata - and answers it as it would be published,{"picture": {...}, "published": <whether one is published already>}, without publishing anything. A front end MUST show it so before publishing (UN-RFC 0022 section 8.5), and before the first one it MUST showpicture_warningfromGET /api/profile.- The same without
previewpublishes it and answers{"picture": {...}, "published": true}. {"remove": true}removes yours; what others kept cannot be taken back.
4.12 Pinned Posts¶
The owner and moderators of a group may pin posts at the top of it, and nodes keep a pinned post beyond the group's window (UN-RFC 0033). The head of a group (section 4.3) has
"pins": [{"id": "<message-id>", "note": "...", "by": "<key>", "by_label": "...",
"pinned": "<ISO 8601>", "subject": "..." or null, "who": "..." or null,
"date": "<ISO 8601>" or null, "beyond_window": false}, ...],
"max_pins": 5, "may_pin": true
pins are the pins in force, newest pin first, as every client of this
project and every node works them out (UN-RFC 0033 section 4.2): only
pins by the owner and moderators of the current manifest count, at most
max_pins, and not of withdrawn posts. subject, who and date are
the pinned post's, null while the runtime does not have it. note is
text the pinner wrote: show it as text. may_pin says whether you may
pin here: you own or moderate the group and it takes pins. Each row also
has pinned. A front end shows the pins above the threads, or as a bar
above a chat, and keeps each pinned post in its place too (UN-RFC 0033
sections 7.2 and 7.3). When there are max_pins pins already, a new pin
makes the last of pins stop counting: a front end MUST say which
before pinning (UN-RFC 0033 section 7.1).
POST /api/groups/<id>/pin with {"id": "<message-id>", "note": "..."}
pins a post (a note is at most 256 characters), and with {"id": ...,
"unpin": true} unpins it; it answers {"ok": true, "marker":
"<message-id of the marker>", "pins": [...]}. Refused with 400 for
anyone but the owner and moderators, in a group whose max_pins is 0,
or to unpin a post that is not pinned. A pin with beyond_window true is
of a post older than the group keeps posts: unpinned, it is gone at
once, so a front end MUST say so before unpinning it (UN-RFC 0033
section 7.1).
4.13 Addresses¶
Every identity, group, post and page has one unseenet: address (UN-RFC
0031 section 3.4.1). The runtime writes them and reads them, so every front
end shares the same addresses and opens every form, the older ones too.
The head of a group (section 4.3) has address, the group's, and each of
its rows has address, the post's; a profile (section 4.8) has address,
the identity's. They carry the node this runtime reads the group from as
an n= hint when that node is an onion address. A front end offers them
to copy.
GET /api/address?a=<address> says what an address names:
{"kind": "identity" | "group" | "post" | "page", "address": "<the current form>",
"web": "https://<entrance>/a/..." or null, "nodes": ["<n= hints>"],
"fingerprint": "...", "key": "<key>" or null, "label": "...", "mine": false,
"group_id": "...", "followed": true, "available": true,
"group": {"id", "label", "title", "type", "view"},
"message": {"id", "subject", "kind", "who", "date"} or null,
"page": {"path", "title"} or null}
with the fields of its kind: fingerprint, key (a key this runtime knows
with that fingerprint, or null) and mine for an identity; for the others
group_id, followed, available (whether your nodes have the group) and
group, with message for a post and page for a page, null while the
runtime cannot find it. The runtime fetches the group's manifest, and for a
post or a page its posts, from your nodes; it never connects to the n=
hints by itself (UN-RFC 0031 section 3.4.1) - a front end asks before
adding one. An older form, such as unseenet:join?g=..., web+unseenet: or the
web form https://<host>/a/... on any host, is answered with its current
form in address; web is the web form on the first listed web entrance
(UN-RFC 0043 section 8.1), without n= hints, or null when none is
listed. A front end offers both to copy. Refused with 400 when it is not an address.
4.14 Feeds¶
A group whose view is feed (UN-RFC 0034) is read like any group
(section 4.3): its rows are its entries, every post or chat line that
answers nothing in the group, newest first by Date, each at depth 0 and
followed by its comments, two levels at most (Session.feed_rows()), and a front end shows them as
UN-RFC 0034 section 4 says. In a group shown as a feed each row also has comment, a
permission (section 4.3) to answer it with a chat line, which is how a
comment is written (UN-RFC 0034 section 4.2): POST /api/groups/<id>/post
with "kind": "chat" and the entry, or the comment answered, last in
references.
GET /api/groups/timeline: is the timeline (UN-RFC 0034 section 7): the
entries of every group in it (UN-RFC 0034 section 6.2: the feeds you
follow unless you took one out, and groups of threads you put in), newest
first by Date and in no other order, each followed by its comments as
its group has them. Each row has
group_label, the feed it is from, address, reply and comment for
that feed; writing from the timeline writes to the entry's own feed. The
head has feeds, how many groups are in the timeline. The runtime asks for the
manifests of followed groups it does not know yet, so that a feed is
known to be one; what changes comes as groups_changed.
Following never sends anything to anyone (UN-RFC 0034 section 6):
GET /api/following:{"people": [{"fingerprint", "key", "name", "label", "feeds": [<feed>, ...]}, ...], "feeds": [<feed>, ...]}, the people you follow with the feeds followed because of them, and every feed you follow. A feed is{"id", "label", "title", "type", "personal"},personalfor a personal group (UN-RFC 0029 section 2.1).POST /api/following{"person": "<key, name, fingerprint or unseenet: address>", "on": true}follows a person - their personal groups and the feeds their profile lists (UN-RFC 0034 section 6.1) - and answers{"ok": true, "fingerprint", "key", "name", "feeds", "new"}; with"on": falseit stops, answering{"ok": true, "unsubscribed": [...]}, the feeds followed only because of them.{"feed": "<group ID>", "on": true}follows a feed on its own, or stops.{"refresh": true}reads the profiles of those you follow again and answers{"ok": true, "new": [...]}, the feeds followed since. Refused with 400 when a person cannot be found.
Your own feed (UN-RFC 0034 section 5) is started only when you ask:
GET /api/feed:{"id": "feed@<fp>", "started": false, "followed": false, "warning": "..."}, with the warning a front end MUST show before it is started (UN-RFC 0029 section 2.1).POST /api/feed{"comments": "open" | "none", "title": "..."}starts it (or follows it again) and answers{"ok": true, "id": "feed@<fp>"}.
A profile of someone else (section 4.8) has feed, the Group ID of their
personal feed, which may not exist, and following, whether you follow
them. Unless you hid it, it also has theirs: {"feed": <Group ID or
null>, "site": <Group ID or null>}, their personal feed and site as far
as they exist, so a front end can offer to look at them - opening a group
does not follow it (#506): GET /api/groups/<id> of a group you do not
follow and have not read fetches it, and an event says when
its posts are here (group-updated, section 5).
4.15 Sites and Pages¶
A group whose view is pages is a site (UN-RFC 0035). Its rows (section
4.3) are every version of every page as they are carried; which versions
count is the runtime's, with the rules every node uses (UN-RFC 0035
section 5):
GET /api/groups/<id>/site:{"id", "label", "title", "home", "owner": {"key", "label"}, "may_write", "max_pages", "index", "address", "pages": [{"path", "title", "home", "id", "who", "who_key", "date", "previous"}, ...]}, the pages in force, the home page first (section 5.2);previoussays whether a previous version is kept,may_writewhether you are one of the site's writers,addressthe home page's. Refused with 400 for a group that is not a site.GET /api/groups/<id>/page?path=<path>: one page in force, the home page withoutpath:{"path", "title", "home", "address", "row": <row>, "previous": {"id", "who", "date"} or null}.rowis as in section 4.3; in itstreea link without a scheme, a path in the same site (UN-RFC 0035 section 8), is theunseenet:address of that page, so a front end opens it as any address (section 4.13). 404 when the page is not in force or not read yet.POST /api/groups/<id>/pagewrites a page, as a job (section 4.4):{"path", "title", "text", "markdown": true, "files": [...]}(files as in section 4.10, pictures pointed at by blob ID),{"path", "remove": true}removes it, and{"path", "restore": true}publishes its previous version again (UN-RFC 0035 section 10.4).{"path", "text", "markdown", "preview": true}writes nothing and answers{"tree"}, the page as readers will see it, for a preview (UN-RFC 0035 section 9.2). The runtime reads the site first, so the new version names the current one (section 9.2). Refused with 400 for a path that is not valid (section 4.1) or when you do not write the site.
GET /api/site and POST /api/site {"title"} are your personal site, as
/api/feed is your feed (section 4.14, UN-RFC 0035 section 7).
POST /api/groups/{gid}/site{"folder", "prune": false, "dry_run": true}: publish a folder of Markdown (and HTML) on this machine as the site's pages (#538). A dry run answers at once{"changes": [{"path", "title", "action": "new" | "changed" | "unchanged" | "remove" | "kept", "source", "files"}], "notes", "problems", "sent": 0}; otherwise it is a job, as a post is, that sends only what changed (new versions naming the old) and, withprune, removes pages no longer in the folder. Problems (too many pages, a page too large) send nothing.
4.16 Private Profiles¶
A profile can be shared with one person or a closed group, where only they can read it and it is shown only there (UN-RFC 0022 section 6) - for someone who wants no public profile, or another one for friends.
POST /api/profile/share{"to": "<key> | <closed group id>", "profile": {...} | {"edit": {...}}}shares one: withoutprofileyour public profile (with your picture inline, UN-RFC 0022 section 8.3), or another, given as forPOST /api/profile. A key's goes to their personal inbox; anything but a key or a closed group is refused, for a public profile is published, not shared.{"to": ..., "withdraw": true}takes back the one you shared there. It answers{"ok": true, "id": "<Message-ID>"}and publishes nothing.GET /api/profile?key=<key>&context=<where>addsshared: the profile that key shared where it is opened, as{"key", "profile", "about_tree", "context", "context_label"}, or null.contextis a closed group's ID, orme- your private posts, your inbox or a chat with them (contact:, section 4.3), which are all one context. Anything else has no shared profile. A runtime MUST NOT answer one shared in another context, and a front end shows it in that context only, saying it was shared with you or with the group.
4.17 Your Nodes¶
A person has one or more nodes; each group is read from one of them, and new groups and the inbox go to the default node, the first (UN-RFC 0001). A front end shows a node by name, in this order:
- the name on the signed known-node list (UN-RFC 0010),
sourceknown; - otherwise the node's own
namefrom its node information (UN-RFC 0011),sourceself. Anyone can give a node any name, also one on the known-node list, so a front end MUST show it as the node's own claim (the runtime'slabeladds "(unlisted)"); - otherwise the start of its onion address,
sourcenone.
The runtime asks a node not on the list for its name once, while connecting, and keeps it in the vault, cleaned: one line, no control or format characters, at most 64 characters. The full address is always given beside the name, for a tooltip or to copy.
GET /api/nodes:{"nodes": [{"address", "name", "source", "label", "description", "operator", "default", "groups"}], "known": [...]}- your nodes, the default first, with how many groups you follow are read from each, and inknownthe listed nodes you do not use yet, in the same form.descriptionandoperatorare the known-node list's (or empty). With?info=1each node is also asked for its node information:reachable(true or false) andinfo(its answer,{}if it offers none, null if unreachable). That waits for the slowest node, at most a minute: a front end shows the list first and asks withinfo=1after.POST /api/nodes{"action": "add" | "remove" | "default", "address": "..."}adds a node, stops using one, or makes one the default (adding it if needed), and answersGET /api/nodeswithoutinfo. A new node's groups are read at once, and new posts on it pushed.removealso answersmoved, the followed groups now read from another of your nodes that carries them, andunavailable, those no node of yours carries - they are never moved to a node that does not carry them; nothing is deleted on the node itself, and the last node cannot be removed (400).
When none of your nodes has answered for a while, the runtime carries on
with a node from the signed known-node list (UN-RFC 0010 section 5) and
makes it the default; when only the default is gone, another of your
nodes that answers becomes the default. Either way it sends
default-node-changed (section 5), and a front end tells the user which
node they use now. An address is an onion address (56 characters and
.onion, with an optional :port) or host:port; anything else is
400.
4.18 Recovery Words¶
An identity's 24 recovery words (UN-RFC 0050 section 4) are its key. The
runtime never keeps them: not in the vault, the state, a log or a file,
and it never saves or prints them by itself (UN-RFC 0050 section 4.3).
What a front end does with page - save it as a file, print it - is the
person's deliberate choice, behind that section's warnings.
POST /api/recovery/words{"passphrase", "identity"}(identityoptional, the default one): the vault passphrase again, even though the vault is open (UN-RFC 0050 section 4.2); 400 when it is wrong. Answers{"identity", "fingerprint", "words", "check", "note", "not_a_wallet", "title", "page"}:checkthe three positions (1-24) to ask back,notethat closed groups do not come back,not_a_walletthe sentence to show with them,pagethe plain text for Save as file or Print.POST /api/recovery/written{"identity"}: the words were written down and checked.POST /api/recovery/reminded{"identity"}: the front end reminded the person, or they skipped at creation. State'swords_reminderis true when to remind again: not written down, and not reminded within a week.POST /api/recovery/check{"words"}: whether the words can be read, for feedback while they are typed; works while locked, keeps nothing.{"ok": true, "words": [...]}(the full words; four letters are enough when typing) or{"ok": false, "kind": "count" | "word" | "checksum", "error", "position"}.POST /api/recovery/restore{"words", "display", "node", "name"}: puts the identity in the vault - as the first setup of a new vault (instead of/api/setup, and thennodeis needed), or beside the identities already there - makes it the default, reads its state, name and profile from the network, and starts the runtime. Answers{"identity", "new", "fingerprint", "key", "label", "profile", "state", "closed_groups", "note"}:stateisread,none(nothing in the network: the default groups) orunreachable;closed_groupsas below.GET /api/recovery/closed-groups:{"groups": [{"group", "label", "owner", "mine"}], "note"}- closed groups you follow whose secret the vault lacks (UN-RFC 0050 section 4.5).POST /api/recovery/ask{"group", "note"}: asks the group's owner, by private post to their inbox, to invite you again; answers{"message_id"}.
4.19 Copying to Another Device¶
Copying identities from one device to another (UN-RFC 0050 section 3)
takes the user on both devices, so the copy lives in the runtime while
they compare the check words. One copy at a time, offered or received.
What happens to an offer comes as an event (section 5): {"event":
"copy", "role": "old" | "new", "state", "check_words", "error",
"added"}.
POST /api/copy/offer{"identities", "group_secrets"}: offer a copy of the vault's identities (all, or the names given) and, unlessgroup_secretsis false, the closed groups' secrets. Answers{"role": "old", "state": "waiting", "code", "qr", "expires", "check_words", "error", "added"}:codeto show as text,qrthe text of the QR code (unseenet-copy:1:...),expiresa Unix time. 400 when this device's Tor cannot open an onion service, or no meeting point answers. When the new device has connected, acopyevent withstatecheckand thecheck_wordsfollows.POST /api/copy/confirm{"match"}: the user's answer. True sends the copy and answers when the new device has it (statedone,addedthe names it added); false sends nothing (cancelled).POST /api/copy/receive{"code", "node"}: on the new device, the typed code or the scanned QR text. Answers{"role": "new", "state": "check", "check_words"}once connected; 400 for a wrong code or one nobody is waiting with. Also the first setup of a new vault (instead of/api/setup;nodeis then needed).POST /api/copy/finish: waits until the old device's user has confirmed the words, adds the copy to the vault, reads the state from the network and starts the runtime. Answers{"added", "state"}(stateas in section 4.18); 400 when the old device stopped.POST /api/copy/cancel: stop the copy offered or being received.GET /api/copy: the copy in progress,{"role": null}if none.
4.20 Your Own Rules¶
The user's own rules for their feed and groups (UN-RFC 0051) run in the client core, so every front end shows the same order and the same "Why?". Without rules nothing in this section changes any other answer.
GET /api/rules:{"text", "on", "rules", "problems"}- the rules as the user wrote them, whether they are used, how many rules are used, andproblems,[{"line", "message"}], the lines that are not (UN-RFC 0051 section 6); andsuggestion, the commented rule set every client offers when the user has none - shown in full, used only if the user saves it (UN-RFC 0051 section 3); andhelp, the language in short, for a client to show beside its editor.POST /api/rules{"text", "on"}, either or both: keeptextas the rules ("" removes them; 400 when longer than 2048 bytes), and turn them on or off - off keeps the text (UN-RFC 0051 section 3). Answers asGET, and agroupsevent follows (section 5).POST /api/rules/check{"text"}:{"rules", "problems"}for a text, keeping nothing - for an editor that checks as the user types.POST /api/groups/{gid}/seen{"until"}: everything in the group dated up tountil(ISO 8601, with a time zone) has been seen - shown on the screen, not opened (UN-RFC 0044 section 4.5). Answers{"seen_to"}; it never moves back.POST /api/threads{"id", "mark"}: watch ("watched") or ignore ("ignored") the thread whose first post isid, or neither ("").
With rules on, the rows of a threaded group (section 4.3) and the
entries of the timeline (section 4.14) carry score, actions (any of
fold, dim, highlight, mark read), hidden and why (UN-RFC
0051 section 10); a threaded group's first rows also thread_mark. The
rows come in the order the rules give: threads ordered, replies never
reordered, and threads whose every post is hidden left out. A client
SHOULD NOT show a row with hidden true, and SHOULD show why when the
user asks why a row is where it is.
4.21 Spam Reports¶
Spam reports (UN-RFC 0045) are followed and applied in the client core, so every front end hides the same posts for the same reasons.
- Every post in every answer carries
reported: null, or{"issuer", "label", "reason", "automatic", "kind", "report"}when an issuer the user follows reports the post (kind"post") or its author's key ("key"). The post keeps its place. A front end SHOULD show it as hidden in that place, with who and why (and "automatic" when set), one action from being shown, and SHOULD NOT hide it silently (UN-RFC 0045 section 7.3). The user's own posts and their contacts' never carry it. GET /api/settings(section 4.8) also saysreport_issuer: whether the user has started areports@group, so a front end can offer publishing a report without asking for more.GET /api/reports:{"issuers", "reasons", "mine"}-issuers,[{"key", "label", "yours", "hierarchies", "on"}], every issuer the user follows or that a hierarchy of their groups names;reasons, the reasons a report can give;mine, the user as an issuer ({"group", "policy", "contact", "automatic"}), or null before they start.POST /api/reports/follow{"key", "label", "on"}: follow an issuer in every group (also turning one a hierarchy names back on), or with"on": falsestop following it, also one a hierarchy names. Answers asGET; agroupsevent follows.POST /api/reports/refresh: read the followed issuers' reports now;{"issuers", "reports"}.POST /api/groups/{gid}/show{"id"}: show a post hidden by a report anyway, on this device.POST /api/reports/start{"policy", "contact", "automatic"}: start or change the user'sreports@group (UN-RFC 0045 section 3), and their inbox if they have none. With"separate": {"name", "display"}the issuer is a new identity beside the user's in the vault, and the user's stays the one in use. An issuer copied into a vault from another device (UN-RFC 0050) is found by itself. Answers asGET.POST /api/reports/publish{"posts", "keys", "note"}: publish a report;posts[{"id", "group", "reason"}],keys[{"key", "reason", "days"}](days1-365, 365 when left out). Answers at once{"job"}: the issuer's postage, and the first time its ripening, can take minutes; the job's events (section 5) give the report's Message-ID.GET /api/reports/mine:{"reports": [{"id", "date", "note", "posts", "keys"}]}, newest first;POST /api/reports/withdraw{"id"}withdraws one.- A report someone sent the user (section 4.6) has
reportin its row,{"id", "group", "author_key", "reason"}: the post it names, so a front end can open it and, for an issuer, publish it. POST /api/groups/{gid}/settings(section 4.7) also takesreport_issuers, the whole list of keys the group's readers follow by default (UN-RFC 0045 section 7.5, [] for none), and for an inboxteam_name(UN-RFC 0047);GET /api/groups/{gid}gives both, the issuers as{"key", "label"}.GET /api/reports/received:{"received": [{"message", "subject", "date", "id", "group", "author_key"}]}, the reports others sent the user (section 4.6), to publish without the reporter (UN-RFC 0045 section 7.4).POST /api/reports/hierarchy{"hierarchy", "keys"}: as its maintainer, the whole list of issuers the hierarchy's readers follow by default, at most 16 (UN-RFC 0045 section 7.1).
4.22 Team Inboxes¶
Team inboxes (UN-RFC 0047) are read with team keys kept in the vault; the owner key is needed only to change who reads.
GET /api/teams:{"teams": [{"group", "name", "reader"}]}, the team inboxes the user reads;readeris false once the owner removed their key (a front end offers to forget it). Each is read like any group (section 4.3) and shown as a mailbox of its own, never mixed into the user's inbox.- A private post that hands the user a team key has
team_invite({"group"});POST /api/teams/accept{"id"}checks it against the inbox's owner and readers and keeps the key:{"group"}.POST /api/teams/forget{"group"}removes a team key. POST /api/privatetakesas_team(a team inbox): written with the team key, on the inbox's behalf, answers going to that inbox. A message written so hasteamin its row,{"group", "name", "verified"}, andwhois the team's name: a front end MUST NOT show it as a person.- As the owner:
GET /api/teams/readers?group={"group", "readers", "team_name"};POST /api/teams/readers{"group", "add": <key>, "team_name"}makes a team key and sends it to that person's inbox,{"group", "remove": <team key>}removes one. Both answer asGET.
4.23 Content Labels¶
Content labels (UN-RFC 0053) describe and never sanction. Every row has
labels: null, or {"warnings": [{"warning", "by", "choice"}],
"topics": [...], "action"} - the post's warnings (the author's tags,
the group's, labellers'; by names who gave each), its topics from
labellers the user follows for topics, and what to do: show, blur
(behind Show anyway) or hide, from the user's choices. A front end
follows action and never hides or blurs silently: it says which warning
and by whom. The user's own posts and their contacts' are always show.
Rows also have the author's tags (UN-RFC 0052), groups their tags.
GET /api/labels:{"labellers": [{"key", "label", "yours", "topics", "hierarchies", "groups", "on"}], "warnings": [{"warning", "label", "choice"}], "mine"}- the labellers that count and where each is named, what the user does with each warning, and their ownlabels@channel.POST /api/labels/follow{"key", "label", "topics"}: follow a labeller in every group, its topics too iftopics;"follow": falsestops following, also one a hierarchy or group names.POST /api/labels/warning{"warning", "choice"}:show,blurorhidefor that warning.POST /api/labels/refresh: read the labellers' channels now.POST /api/labels/profile{"profile"}: the age guide (UN-RFC 0053 6.1) -under-13,13-17oradult- applied as the user's choices per warning; the profile itself is not kept.GET /api/labelslists them asprofiles:[{"id", "label", "choices"}].POST /api/labels/start{"policy", "contact", "automatic", "topics"}: start or change the user'slabels@channel.POST /api/labels/publish{"posts": [{"id", "group", "labels"}], "note"}: answers at once{"job"};"labels": []takes the user's earlier labels of a post back.GET /api/groups/{gid}?all=1: Show all (UN-RFC 0051 section 3) - the view without the user's rules, for the feed, a threaded group and For you; the head saysshow_all. Nothing is saved. For you's rows carry their[foryou]results (actionsmay holdquiet: listed, not counted, no notification), andPOST /api/groups/for-you:/seenmarks it seen (UN-RFC 0051 5.5).GET /api/groups/tag:WORD: every post with the tag in the groups and feeds the user follows - the author's tags, the group's, and topics of labellers followed for topics - newest first, as a feed (viewfeed,label#word); writing goes to each post's own group.POST /api/groups/{gid}/language{"text", "reply_to", "kind"}: what to suggest the author writes in, from the text as they type (#686) -{"codes", "from"},frombeingtext,answered(a comment: the post it answers),conversation(a chat line),group,you,profileor"". Local and cheap: a client asks at pauses in typing.GET /api/languages:{"languages": [{"code", "name", "english", "recognised"}]}- the languages to offer, the ones a text can be told to be in first; a language's own name and its code, no flags.POST /api/groups/{gid}/language/choose{"languages"}: what the user writes chat lines and comments in, in that group - winning over the suggestion for them;[]takes it back. Answers aslanguage(fromchosen).POST /api/groups/{gid}/posttakeslanguages(UN-RFC 0052 5a), what the author chose, remembered for the group; the group's head hassuggested_languages:{"codes", "from"}-you,group,profileor""(none: send no header), and settings take the group'slanguages.POST /api/groups/{gid}/posttakestags, the author's own (at most 8), andPOST /api/groups/{gid}/settingsthe group'stagsandlabellers(UN-RFC 0053 section 5).
4.24 Comments on One Entry¶
A thread takes comments (in a channel) or replies (in other groups) for
the shorter of the group's comment_days and the entry's own time, and
its author, the owner and moderators can close and open them (UN-RFC
0030 sections 3.1-3.3).
- The head of a group (section 4.3) has
comments(a channel'sopenornone, null for other types),comment_days(null: no limit) andcomment_days_possible, false for inboxes and local groups. POST /api/groups/<id>/posttakescomment_daysfor a new thread: a whole number of days,0(no comments at all) up to the group'scomment_days. The runtime writesComment-Daysonly when it differs from the group's, and refuses more than the group's, or a reply, with 400 before the job starts.- A row of a thread's first post (section 4.3) has
comments, null where there is nothing to say (no limit, no close), else{"open": true | false, "reason": null | "deadline" | "closed", "until": "<ISO 8601>" or null, "note": "...", "by": "<key>" or null, "own_days": n or null, "group_days": n or null}, andmay_close_comments. A front end tells the reader when comments have closed, with the note of a close as text, instead of letting a reply fail, and shows when they close once that is less than a day away. POST /api/groups/<id>/comments{"id": "<message-id>", "close": true | false, "note": "..."}closes comments on that entry (a note is at most 256 characters, none in a closed group) or opens them again, and answers{"ok": true, "marker": "<message-id>", "comments": {...}}. Refused with 400 for anyone but the entry's author, the owner and moderators, for a reply, and for the author opening what the owner or a moderator closed.write_permission, and with it what the runtime says before a reply is written, answerscomments_closedwith the reason and the note.
5. Events¶
GET /api/events is a WebSocket (subprotocols unseenet and the token,
section 3). Each message is one JSON object with its name in event:
- every event of the core, as
to_dict()gives it (client.events):status,group-updated,groups-changed,node-state,tor-state,inbox-ready,refreshed,release-offer,ripening,pow-progress, anddefault-node-changed(address,previous,reason:fallbackordefault-down, section 4.17); job:{"event": "job", "job": "<id>", "group": "<id>", "state": ...}withstateone ofripening,pow,sent,reached,failed, andfraction(0 to 1) withripeningandpow,message_idwithsentandreached,errorwithfailed;ripening-paused:{"event": "ripening-paused", "paused": true | false}afterPOST /api/ripening, so every window follows;ripening-target(target) after a new level was chosen.statuscarriesidle(section 4.1) when the runtime says it, so a front end can follow without asking the state again.
A front end that connects late asks GET /api/state and the groups
again; events are not replayed.
6. Extensions¶
A runtime MAY offer more, under /x/<name>/ (for example unseenet-gui's
owner tools or a bootable machine's Wi-Fi and power settings, /x/gui/),
with the same security rules. Paths under /api/ are this document's.
An extension's own events on /api/events are named x-<name>-...
(x-gui-update for unseenet-gui's update progress); a front end ignores
events it does not know.
6.1 Not in This API¶
Running a node or a web entrance, and having it join the network, is not
a client's work: a node asks the hierarchy to list it in its own way
(UN-RFC 0010 and 0021 for nodes, UN-RFC 0038 for gateways, UN-RFC 0043
for web entrances), and its
operator configures it in node.toml. A program that does that work for
an operator MAY use this API for the client part (posting the request
as the operator's key, section 4.9) and does the rest itself.
What only one front end on one kind of machine needs - Wi-Fi, power,
the keyboard layout, installing an update of a bootable image - stays
an extension (/x/gui/ in unseenet-gui).
7. Rationale¶
Why HTTP and a WebSocket. Every language and every browser speaks them, so a front end can be a page, a native app or a script. A local socket file would keep out other users without a token, but a browser cannot open one.
Why this much. A front end that can read and post but not report a post, ignore a person, edit what it wrote or run its own group would send people back to another client for half of what they do, and each such front end would invent its own extension for it. So everything a client of this project offers a person is here; only what depends on one kind of machine is left to extensions.
Why services use it too. A bot or gateway posts the same way a person
does - postage, ripening, signatures - and the project wants one code
path for that (Session). Giving them the same API, without unlock and
with prebuilt articles, means a bot in any language gets ripening and
postage right without reimplementing UN-RFC 0025.
Why jobs. Postage takes seconds on an old laptop and a new key ripens for minutes before its first post (UN-RFC 0025). A request that waited would time out; a job answers at once and tells how far it is.
8. Security Considerations¶
- Other users on the same machine can connect to
127.0.0.1. The token, which only the front end knows, keeps them out. The.apifile is readable by its owner only. - Web pages cannot read the API: no CORS,
OriginandHostare checked, and the token is not in any URL a server sees. - The front end itself is trusted to show what the person sees, not with keys: a compromised front end can post as the person while the vault is unlocked, but cannot take the keys away.
- Posts are hostile text. Anyone can post anything. Rendering in the runtime, to a tree in which HTML is only ever text, means no front end has to get Markdown or escaping right on its own; with the CSP, a post can neither run a script nor load anything from outside.
- Logs. A runtime MUST NOT log the token, the passphrase or bodies of private posts.
9. Changes to Other Documents¶
None. UN-RFC 0043's web entrance serves the client to a browser over the network and is not this API.
Copyright Notice¶
Copyright (c) 2026 the persons identified as the document authors.
This document is licensed under CC BY 4.0, see rfc/LICENSE.md.