The situation
You have plenty to say about your business. You just never sit down to write it. On the drive home you record a two-minute voice memo instead: the new hours, why you changed them, what customers asked. Your phone already makes a transcript. One command turns it into a blog post on your site, in your own words, checked against the site and waiting as a draft with a preview link. You read it and publish it, or you don’t.What you’ll build
An Ace command,node ace post:from-notes, in your own AdonisJS app. It hands the transcript to an
agent built with OpenRouter’s Agent SDK (@openrouter/agent). The agent’s tools are six calls of the
Fetchski Attract SDK: the site’s post template, its authors, its related posts, its image library, a
dry-run check, and a draft save. When the agent is done, your code (not the agent) makes a private
preview link and puts the draft in the CMS’s review queue. Nothing goes live until a person publishes it.
- A Fetchski Attract site you can sign in to as someone who may manage API keys.
- Node.js 26.12.1 or later (
node --version). This page was built with AdonisJS 7.5,@openrouter/agent0.11 and Zod 4.6. - An OpenRouter account with credit and an API key (openrouter.ai/settings/keys).
The agent runs on
~anthropic/claude-opus-latest, OpenRouter’s name for the newest Claude Opus, so it moves to each new Opus by itself. The command prints what each run cost, and every run stops at $1. When you make the key, give it a credit limit (for example $10), so nothing can spend more. - About 30 minutes to set it up.
Make the API key
The app gets its own key, with only what the agent needs. It can write drafts but never publish, and it can use library images but never add one (a new image would go live at once).Open API Keys
https://acme.example/cms-login. Press ⌘K
(Ctrl+K on Windows and Linux), type API Keys and press Enter.
Click Create API Key.Name it
Voice memo to blog post.Pick the Blog post drafts pack
content:blog:publish the key can’t take a post live, and
without content:images:write it can’t add an image.Check Granted before you create the key: it should list exactly the 5 scopes above.
Your site’s packs can be edited, so if it shows any other scope (a publish or delete one, say),
take it out with Edit scopes, or have someone who manages API keys choose Restore default
packs in Scope Packs.No Blog post drafts pack in the list? Someone who manages API keys restores it: ⌘K, Scope
Packs, Restore default packs. Or choose Pick scopes one by one and tick the five
scopes above.Pick the signing mode
Create it and copy both values
fsk_acme_…) and
the Signing secret (fss_…). They are shown once: keep them for steps 2 and 4 below.Step by step
Create the app
Your AdonisJS project has been created successfully!, then a prompt inside
memo-to-post.Point @fetchski at your site
memo-to-post, with the Secret token you copied above.Your own site serves the @fetchski packages, and any API key from it lets you in. In your
project’s folder (beside its package.json), make a file named .npmrc holding these two lines:@fetchski packages come from your site; the second is the key to sign in with.
Put your site’s address in both lines (set your site in the bar under this page’s title and the
example shows it), and paste your whole key in place of fsk_acme_…. Write the key itself, never a
variable or a value from .env: npm and pnpm don’t read .env, and pnpm refuses a variable here..gitignore (make the file if
there isn’t one):@fetchski/… package, with
your key.You should see: .npmrc beside package.json, and .npmrc as the last line of .gitignore.Install the four packages
.npmrc, run the line for the package manager your
project uses:@fetchski/attract-cms-sdk calls your site and @fetchski/attract-cms-adonis fits it into
AdonisJS; @openrouter/agent runs the agent and its tools, and zod describes each tool’s input.You should see: added … packages. npm may also list @openrouter/sdk under
install-scripts: that script only checks types, and the app doesn’t need it.Point the app at your site
.env, the
rules that check them to start/env.ts, and config/attract.ts, and registers the provider that
puts the site’s client in the container. A mistyped key or address writes nothing.You should see: DONE: lines for config/attract.ts, .env, start/env.ts and adonisrc.ts,
then [ success ] @fetchski/attract-cms-adonis is configured. Next: node ace attract:check. Run
that: every row reads ok, and it ends [ success ] The site acme is ready.Add the model's environment variables
.env and add these lines at the end, with your OpenRouter key:start/env.ts with this whole file:.env.test with this whole file:configure added, and the two OpenRouter
ones. The app refuses to boot when a value is missing, and the keys stay Secrets, which print as
[redacted] in logs. Tests never call the site or the model, so placeholders do there.You should see: nothing yet: the next steps use them.Gather the model's options
maxSteps and maxCostUsd cap every run, whatever the model does. A post needs five to
eight steps.Put the model in the container
OpenRouter is a class, so it is its own container key, as the package made SiteClient:
@inject() hands both to the service, and a test replaces each with a fake.You should see: DONE: create providers/openrouter_provider.ts and DONE: update adonisrc.ts file.Give the agent its tools
app/services/post_writer_tools.ts. It is the file pinned beside this page; here it is again
in full:One draft, at most
save_draft refuses a second save, and sends publish: false. ai: true marks the post as
AI-written in its version history.Errors go back to the agent
Write the agent
callModel() runs the whole loop: the model picks a tool, the SDK runs it, the answer goes
back, until a stop condition fires. Three stop it here: 12 steps, $1, or the draft is saved.
After a stop the model gets one last turn without tools, so it always ends with its note to you.Add the command
startApp: true boots the app, so the container has both clients; @inject() on run()
hands it the PostWriter. A failure sets exit code 1, so a script around it can tell.You should see: node ace post:from-notes --help prints the description, <file> and
--author.Format, lint and type-check
configure and make:provider write adonisrc.ts in a shape Prettier changes, and
codegen writes the index files tsc reads.You should see: npm run lint and npm run typecheck print only their own command lines
(> eslint ., > tsc --noEmit), no errors.Run it
Save a transcript
Run the command
--author out:Read it, then publish or dismiss it
Submitted Drafts and press
Enter. Open the post, change anything you like, and click Publish. Not happy with it?
Dismiss it there: it stays an unpublished draft.If something goes wrong
Nothing written: Not an API key
Nothing written: Not an API key
configure refused the key: it isn’t the whole token, which is fsk_<site slug>_<key id>_<secret>,
exactly as Your API key is ready showed it. Copy it again and run configure again; if it’s
lost, rotate the key under API Keys to get a new one. node ace attract:check says which
part of the setup fails: the config, the site, the key or the clock.EnvValidationException: Missing environment variable
EnvValidationException: Missing environment variable
Missing environment variable "OPENROUTER_API_KEY" (or another name): that line isn’t in .env.
Add it as in step 5 of Step by step. In tests, it’s missing from .env.test.[ error ] The agent saved no draft
[ error ] The agent saved no draft
- The site refused a call. The reason quotes the site’s message, which names the missing scope
(add it to the key under API Keys) or says the key was refused (check
FETCHSKI_ATTRACT_API_KEYandFETCHSKI_ATTRACT_SIGNING_SECRET). - The post never passed its checks in 12 steps. Clearer notes help: one topic per memo.
- The run reached $1. A very long transcript costs more per step: split it, or raise
maxCostUsdinconfig/openrouter.ts.
LOG_LEVEL=debug in front:
LOG_LEVEL=debug node ace post:from-notes storage/notes/saturday-mornings.txt.… is too short to make a post from
… is too short to make a post from
Preview: open it in the CMS, under Submitted Drafts
Preview: open it in the CMS, under Submitted Drafts
Test it
The tests fake both sides.fakeAttract() puts a fake site in the container for one test: it
answers every call and records it.
For the model, a tiny server on 127.0.0.1 answers OpenRouter’s requests in its own stream format, from
a script of turns: so the real agent loop runs, with the real tools, and nothing is sent or spent.
The fake model
serverURL points a real OpenRouter client at it, so the test swaps in that client (Japa’s
swap(), restored after the test) and nothing else changes.The agent's tests
The command's tests
ace.ui.switchMode('raw') keeps the command’s log lines, so the test can assert them.
Commands live outside app/, so the test imports this one by path.Run them
Turn it off
Revoke the site key
API Keys, open Voice memo to blog post, and switch
Enabled off (or delete it). The app’s next call is refused.Revoke the OpenRouter key
.env.Related
Import a post from Markdown
importBlogPost() takesTesting in AdonisJS
fakeAttract() and the assertions