Skip to main content

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.
Before you start, you need:
  • 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/agent 0.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

Sign in to your site’s CMS at 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

Label: Voice memo to blog post.

Pick the Blog post drafts pack

Under Scopes, keep Start from a pack and pick the Blog post drafts pack. It ticks these five scopes:Add nothing else. Without 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

Request signing: Token + signature on writes. The SDK signs each write for you with the key’s signing secret.

Create it and copy both values

Click Create key. Under Your API key is ready, copy the Secret token (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

Terminal
Why: the API starter kit is the smallest AdonisJS 7 app with Ace, Japa tests, ESLint and Prettier set up. It starts on SQLite, which this Recipe doesn’t use.You should see: Your AdonisJS project has been created successfully!, then a prompt inside memo-to-post.

Point @fetchski at your site

Do this in 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:
.npmrc
The first line says @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.
Any API key from your site works, the same one your app uses included: its scopes and signing mode don’t matter for installing, only that it’s switched on and not past its expiry date. No key yet? Make one in the CMS under API Keys (Authentication).
Then keep the file out of git. Add this line to the project’s .gitignore (make the file if there isn’t one):
.gitignore
.npmrc now holds a key to your site: never commit it. If git already tracks a .npmrc, stop tracking it with git rm --cached .npmrc. Keep these lines in this project’s .npmrc only, never in the .npmrc in your home folder. In CI, write the same file from a secret before installing.
Why: your site serves the Fetchski SDK, so npm asks it for every @fetchski/… package, with your key.You should see: .npmrc beside package.json, and .npmrc as the last line of .gitignore.

Install the four packages

In a terminal, in the same folder as the .npmrc, run the line for the package manager your project uses:
401 Unauthorized? Your site refused the key in .npmrc: it’s mistyped or cut short, switched off, or past its expiry date. Check too that .npmrc sits in the folder you ran the command in.
Why: @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

Terminal
It asks for your site’s address, then the Secret token and the Signing secret you copied (both hidden as you type).Why: it reads your site’s slug from the key, writes the address and both values to .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

Open .env and add these lines at the end, with your OpenRouter key:
.env
Replace start/env.ts with this whole file:
start/env.ts
And replace .env.test with this whole file:
.env.test
Why: the whole file is the starter kit’s, the rules 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

Create the file:
config/openrouter.ts
Why: maxSteps and maxCostUsd cap every run, whatever the model does. A post needs five to eight steps.

Put the model in the container

Terminal
Then replace the file it made:
providers/openrouter_provider.ts
Why: 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

Create app/services/post_writer_tools.ts. It is the file pinned beside this page; here it is again in full:
app/services/post_writer_tools.ts
Why: each tool is one SDK call with a plain description and a Zod input schema, so the model knows when to use it and what to send. Each answers only the fields the agent needs, which keeps every step small and cheap.

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

A tool that throws (a check the site refuses, a missing scope) hands the agent the SDK’s message, which says what to fix.

Write the agent

app/services/post_writer.ts
Why: 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

Terminal
Replace the file it made:
commands/post_from_notes.ts
Why: 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

Terminal
Why: 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

Most phone recorder apps and meeting tools show a transcript: copy it into a text file. Make a folder for them:
Terminal
Then save the transcript there, or use this one:
storage/notes/saturday-mornings.txt

Run the command

Name an author exactly as the blog lists them, or leave --author out:
Terminal
You should see, after 20 to 60 seconds (the title, link, cost and note are the agent’s, so yours will read differently):
Terminal

Read it, then publish or dismiss it

Open the Preview link: it is the draft as visitors will see it, with no sign-in. Send it only to the people reviewing it.To publish, sign in to the CMS, press ⌘K, type 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

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.
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.
OpenRouter refused OPENROUTER_API_KEY. Copy the key again from openrouter.ai/settings/keys; it starts with sk-or-v1-.
The lines under it are the agent’s own reason. The usual ones:
  • 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_KEY and FETCHSKI_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 maxCostUsd in config/openrouter.ts.
To see each call the app made to the site, run it with LOG_LEVEL=debug in front: LOG_LEVEL=debug node ace post:from-notes storage/notes/saturday-mornings.txt.
The file has fewer than 200 characters. A minute of talking is plenty.
The site saved the draft but sent no address for it. It is in Submitted Drafts all the same.
Your site refused the key in .npmrc: it’s mistyped or cut short, switched off, or past its Expires date. Paste the whole key again (step 2), and run the install from memo-to-post, where .npmrc is.

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

tests/helpers/fake_model.ts
Why: 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

tests/unit/post_writer.spec.ts

The command's tests

tests/unit/post_from_notes.spec.ts
Why: 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

Terminal
You should see:
Terminal

Turn it off

Revoke the site key

In the CMS, ⌘K → 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

Delete it at openrouter.ai/settings/keys, then remove the lines you added to .env.
Nothing runs on its own: the command does work only when you run it. Drafts it already made stay in the CMS until you publish or delete them.

Import a post from Markdown

Every front matter key and option importBlogPost() takes

Testing in AdonisJS

fakeAttract() and the assertions

Required scopes

The scope each operation needs

Update your service map from a note

The same kind of agent, on your service areas