Every call the mojo API offers, what it takes, what it hands back, and
when it refuses. That last column is the one worth reading. Most of these answer
null or false rather than throwing, so a plugin that quietly does
nothing is usually a call that quietly refused — and the reason is almost always one of
three: the page is locked, the page is a file, or the page is a board that cannot hold
loose text.
A ref is a page name or a page id. Names are matched the way links are, so aliases and different capitalization work.
| Call | Hands back | Refuses when |
|---|---|---|
mojo.pages() | an array of {id, name, type, tags, aliases} for every page | never |
mojo.currentPage() | the same shape, for the page you are looking at | null if no page is open |
mojo.read(ref) | the page's plain text | null if there is no such page, or it is locked |
mojo.readHtml(ref) | the page's stored HTML | null, same reasons |
mojo.exists(ref) | true if a page answers to this name | never |
mojo.getMeta(ref, key) | one page-meta value | null if the page or key is absent. Throws if you pass only one argument |
await mojo.readData(ref) | the bytes of a file page — a PDF, an image — as an ArrayBuffer. The other half of addFile | null if there is no such page, it is locked, or it is a page of writing rather than a file |
| Call | Hands back | Refuses when |
|---|---|---|
mojo.write(ref, text) | true if written | false for a missing or locked page, a file page, a canvas, or a card board |
mojo.append(ref, text, opts?) | true if appended | false for a missing or locked page, a file page, or a canvas. Throws if you pass only one argument |
mojo.insertAtCursor(text, opts?) | true if inserted | false if no page is open, or it is a file page |
await mojo.create(name, text?, opts?) | the new page's id — or the existing page's id if the name is taken | never; it does not overwrite. A page that was already there keeps the folder it is in |
mojo.move(ref, folder) | {ok, moved, reason?, message?, page?, folder?, from?} — ok is whether the page is in that folder now, moved whether anything changed | ok: false when no page answers to the name, when the text names no folder, or while a preview window or an unsettled change means filing would not last. It never makes a page |
mojo.setMeta(ref, key, value) | true if written | false for a missing or locked page |
await mojo.exportFolder(files, label?) | the folder you chose, having written the files into it. files is an array of {name, text} | null if you close the chooser. It asks every time and remembers no folder — a plugin that runs when a document opens runs with no gesture from you, and a remembered folder would make that a silent write |
A page a script makes, and a file it keeps, arrive Unfiled unless you say otherwise. For one page that is fine. For a plugin that collects every paper a report cites, it is the difference between a library and a heap.
Both create and addFile take a folder:
await mojo.addFile('PMC1234567.pdf', bytes, { folder: 'PDF Library' })
await mojo.create('Reading list', '', { folder: 'Reading/2026' })
A slash nests: 'Reading/2026' means the folder 2026 inside the folder
Reading. Folders that are not there yet are made. One that is already there is used again
rather than duplicated — matched without regard to case or spacing, so a plugin that runs
every morning asking for pdf library lands in the PDF Library it made on the
first morning instead of leaving you a folder for every day of the week.
The path is read from the top down. 'Papers' means a folder called Papers
at the top level — never one of that name buried further in, which would put a reader's
files somewhere the person who wrote the plugin has never seen.
Two things it will not do. A folder name cannot contain a slash, because the slash always
nests. And create files only a page it actually made: if the name was already taken
it hands back the page that was there and leaves it where you had put it, because a call that
does nothing should not quietly move your work.
opts is { format: 'text' } unless you say otherwise.
Pass { format: 'html' } to add real markup — a table, a list, a link —
rather than the characters that would have written one. Either way the markup is scrubbed
the same as anything else that reaches a page.
Saying where a page lands is only half of filing. The other half is the one that comes up later:
None of those is about a page arriving. They are about a library that already exists, which is
what mojo.move is for:
const r = mojo.move('Gait retraining 2021', 'Reading/2021')
if (!r.ok) mojo.log(r.message)
The folder is read exactly as create and addFile read theirs —
a slash nests, a folder that is not there is made, and one that is already there is used again
whatever the case or spacing. One rule, so the same text means the same folder whichever call you
write it in.
It hands back an answer rather than a yes or a no, because there is more than one way for a move not to happen and "false" cannot tell you which:
| Field | Says |
|---|---|
ok | whether the page is in that folder now |
moved | whether anything actually changed |
reason | one word to switch on: already-there, no-page, no-folder, cannot-file-now |
message | the same thing in a sentence, to show somebody |
page / folder | their names as this wiki spells them |
from | the folder it came out of — absent when it was in none |
Branch on ok, never on the answer itself, which is always there and always
truthy. A page already in the right folder is ok and not moved: going
through a shelf, most of it is usually already in order, and that is a success rather than a
failure to report.
let fixed = 0
for (const p of mojo.pages()) {
const year = mojo.getMeta(p.id, 'Year')
if (!year) continue
const r = mojo.move(p.id, `Reading/${year}`)
if (r.moved) fixed++
else if (!r.ok) mojo.log(r.message)
}
mojo.log(`${fixed} moved`)
It never makes a page. A name that answers to nothing is refused and says so, rather than becoming a new empty page — a repair run should not turn a typo into an entry in your wiki. It never removes one either: a move is a page changing folders and nothing else.
And it always needs a folder. An empty name is refused rather than read as "take it out of its folder", because that text is nearly always a name that was built out of something that turned out to be blank — and unfiling a library is not a thing to arrive at by accident.
A folder that mirrors one on disk is filed into, not written into. Dragging a page onto a followed folder yourself offers to write it out as a file there, and that offer is a real decision — the page converts, leaves the wiki, and lives on your disk from then on. A script moving a page into one files it in the sidebar and leaves the page where it is, exactly as the sidebar’s own Move does. Writing a file out is a thing you ask for, not something a plugin can arrive at by naming a folder.
A page with a password moves like any other. Everything else a script cannot do to a locked page is about its words; which folder holds it is not one of its words, and dragging one in the sidebar has always worked. The alternative would be that putting a password on your papers means never being able to have a script tidy them, in exchange for hiding nothing.
Filing is an arrangement you made, so a script that rearranges it says so. What a script moves is written into that run’s entry in the Scripts palette, a line per move naming the page and the folder — and when the run ends, one Put back appears covering the whole run, not just its last move.
That is deliberate. Moving a page in the sidebar yourself offers an Undo for that move; a script moves in a loop, so an offer per move would replace itself twenty times over and leave you able to take back only the last one — a way back that looks like one and is not. So the run is the unit: press Put back and every page the run moved goes back where it was, in the position it held, not merely into the right folder. Any folder the run had to invent goes with them, so long as nothing has been put in it since — pages home and nine empty year folders left standing is not the arrangement you had either.
The offer waits. A script on an Event: page finishes while you are looking at something else, so unlike every other pill in the app this one carries no timer: it sits there until you answer it. Press Put back to undo the run’s filing, or close the notice — its ✕, or Escape — to leave the pages where the run put them. If a second run finishes before you have answered the first, the newest is shown and says how many are still waiting behind it; each answer brings the next one forward.
The lasting record is the run’s entry in the Scripts palette, which ends with a line counting what it moved. A long run lists the first twenty-five moves and then says how many it has not listed, so its own output stays readable and you are not left counting twenty-five lines and taking them for the whole run — Put back covers every page either way. Moves it could not make are listed and counted the same way, so a run over a shelf where nothing resolves cannot bury its own log under a line per failure.
| Call | Hands back | Refuses when |
|---|---|---|
await mojo.fetch(url) | {status, headers, body}, with body as a string | status: 0 and an error in words: not https, refused by you, a private address, too large, or too slow |
await mojo.fetchData(url) | the bytes, as an ArrayBuffer. The one to use for a PDF — fetch decodes what comes back as text, which ruins one | null, with the reason written into this run’s log |
await mojo.fetchResult(url) | the same bytes with the reason attached: {ok, status, reason, message, url, bytes} | never — it always answers, and ok says whether anything arrived |
When something does not arrive, fetchResult says which kind of nothing it was.
Collecting the papers a report cites, a publisher that refuses automated downloads, a paper that
was never there, a file over the size ceiling and a question you declined yourself all used to
arrive as the same empty answer. reason is one of http-status,
too-large, timeout, network, not-https,
blocked-address, declined-now, declined-earlier,
redirect-refused, bad-redirect, not-an-address,
no-document, unreadable or too-many-redirects — so a
script can try another source for one and stop asking for another.
url is where the answer actually came from, after any redirects. A publisher
that answers a PDF address with its landing page does so with status 200, and this is how you
tell.
The first time a plugin asks for a site, MojoPad asks you — naming the site and the document — and remembers your answer for that document. You are not asked twice for the same site, and a plugin cannot reach anywhere you have not agreed to.
const r = await mojo.fetch('https://api.example.com/thing.json')
if (r.status !== 200) { await mojo.alert(r.error || `Got ${r.status}`); return }
const data = JSON.parse(r.body)
Only https, and only public addresses. A plugin is code you are
running against your own notes; a plaintext request would put the whole exchange in front of
anyone in between, and an address on your own network is not the web. Redirects are followed,
but each hop is checked again — a site you allowed cannot hand you to one you did not.
Responses are capped and requests time out, so a plugin cannot pull something endless into memory or hang while you wait.
| Call | Does |
|---|---|
mojo.navigate(ref) | opens a page in this window. Returns nothing, and does nothing if the page is not found |
await mojo.alert(message) | shows a message and waits for it to be dismissed — the way to interrupt on purpose |
await mojo.prompt(label, value?) | asks for a line of text. null if canceled |
mojo.today() | today's date as a string, in your date format |
mojo.log(…) | collects output, shown when the run finishes |
mojo.event | present only on an Event: page — what triggered the run |
mojo.collections() | the folder tree of this wiki — each folder with its id, its title and its nesting — so a script can file what it makes rather than leaving it Unfiled, and can name a folder by something that survives a rename |
await mojo.mcp.servers() | the tools you have connected, by the names you gave them |
await mojo.mcp.tools(name) | what one of them can do |
await mojo.mcp.call(name, tool, args) | asks one to do it |
A plugin can reach the tools you have connected. This reference did not say so, which read as though it could not — the opposite of the truth. A reader was told by an assistant to write a separate program and install it alongside, for something that was already one call. They are reached by the name you gave them in Settings, and a wiki reaches only the ones you granted it.
The Scripts palette keeps the last few runs — from the runner, from the Plugin menu and from Event pages alike. Each one says what ran, when, what it printed, and what failed. Nothing else in MojoPad writes there, so everything in it came from your own code.
A failure that happened while running names the line it happened on. A script that would not parse has no line to name — the failure is found before any of it runs — and MojoPad says so rather than pointing at a line whose code is fine.
macOS replaces straight quotes with typographic ones as you type — "
becomes “ and ” — which is right for writing and is not
valid code. Code that looks perfect stops parsing, and the error names a line with nothing
wrong on it.
MojoPad recovers: if a script does not parse as written, it tries again with the quotes straightened, and says so in the output when that is what fixed it. A script that already parses is run exactly as you wrote it, curled quotes and all — a typographic quote inside a string is your output, not a mistake.
To stop it happening at all: Edit ▸ Substitutions ▸ Smart Quotes. That is your Mac's setting, not MojoPad's, and it applies wherever you are typing.
Both used to fail silently, and both were reported by somebody who lost an afternoon
to them. mojo.append(text) with one argument resolved your CONTENT as a
page name, found no page called that, and returned false. mojo.getMeta(key)
with one argument failed inside on a key that was never passed. They now throw, and the
message names the shape they wanted.
mojo.log records; mojo.alert interrupts. Anything a plugin logs is
kept in Scripts in the command palette, under Recent runs — the last few runs,
each with what it printed, and, when one failed, the error and the line it failed on. Comparing a run
with the one before it is half of debugging, so they are held rather than shown once and lost.
A run appears under Recent runs the moment it starts, not when it finishes, with the time it has been going beside it. A plugin that collects a few hundred papers takes as long as it takes; what matters is being able to tell that it is still working rather than stuck.
Stop is beside it while it runs. A script cannot be interrupted in the middle of a calculation — nothing can reach into a loop from outside. What Stop does is refuse the next thing the script asks MojoPad to do: the next page it reads, file it keeps, or address it fetches. Any script that is actually doing work ends within moments. A script doing nothing but arithmetic has to be waited out, and closing the wiki ends it.
Only one Event: page runs at a time, so a script that never finishes holds up the rest. If an event does not fire and the log says another script was still running, that is what has happened — the run will be listed here, and stopping it releases the others.
A run that logs something says so in the status line rather than stopping you. When you do
want to stop the reader — a question, a warning, something they must see before carrying on
— that is await mojo.alert(…). An error always interrupts, whether you asked
for it or not.
Three boundaries, and each one is yours to set. This chapter used to say a script could
not open a connection, seventy lines below the table documenting mojo.fetch. That was
wrong, and it was the sentence somebody careful would have relied on. What is actually true:
mojo.fetch,
mojo.fetchData and mojo.fetchResult ask you the first time a script
reaches a given site from a given wiki, and remember the answer only if you say to. Addresses on
your own network are refused outright, and so is anything that is not https.mojo.exportFolder opens a folder chooser on each call and writes only there. It has
no remembered destination, deliberately: a plugin that runs when a document opens runs with no
gesture from you at all, and a remembered folder would make that a silent write.A script cannot change any setting, cannot start another program, and cannot read a file from your disk that is not already a page in this wiki.
Scripts run only in a document you have said may run them, and only from pages you wrote yourself. See Nothing runs until you say so under Scripting: Plugins.
A plugin that adds a formatted summary to the page you are looking at:
const here = mojo.currentPage()
if (!here) { await mojo.alert('Open a page first.'); return }
const words = (mojo.read(here.id) || '').split(/\s+/).length
const ok = mojo.append(here.id,
`<p><b>${words}</b> words, counted ${mojo.today()}.</p>`,
{ format: 'html' })
if (!ok) await mojo.alert('That page will not take an append — is it locked?')
Note the last line. Checking what a call handed back is the difference between a plugin that works and a plugin that appears to.