> ## Documentation Index
> Fetch the complete documentation index at: https://docs.fetchski-attract.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Turn a voice memo into a blog post draft

> An AI agent in your AdonisJS app turns a voice memo's transcript into a checked draft post on your site, in your own words, with a private preview link for review.

```ts app/services/post_writer_tools.ts wrap theme={"dark"}
import { tool } from '@openrouter/agent'
import type { BlogImportResult, SiteClient } from '@fetchski/attract-cms-sdk'
import { z } from 'zod'

/** What the run saved, for the code around the agent (never the model's word for it). */
export interface PostWriterRun {
  draft: BlogImportResult | null
}

/**
 * The site, as the agent sees it: six tools, each one SDK call. Every
 * write is a draft; nothing here can publish, and no image is created.
 */
export function postWriterTools(site: SiteClient, run: PostWriterRun) {
  const getPostTemplate = tool({
    name: 'get_post_template',
    description:
      "The site's Markdown template for a blog post: every front matter key, and the blog's categories. Read it first.",
    inputSchema: z.object({}),
    execute: async () => {
      const template = await site.blogPosts().getBlogMarkdownTemplate()
      return { template: template.content }
    },
  })

  const listAuthors = tool({
    name: 'list_authors',
    description: "The blog's authors, by name. Credit the post to one with authorName.",
    inputSchema: z.object({}),
    execute: async () => {
      const authors = await site.authors().listAuthorsPick(['title'])
      return { authors: authors.map((author) => author.title) }
    },
  })

  const findRelatedPosts = tool({
    name: 'find_related_posts',
    description:
      'Published posts that match a few words. Link to one or two that a reader of this post would want next.',
    inputSchema: z.object({
      words: z.string().min(2).describe('A few words about the topic, e.g. "spring opening hours"'),
    }),
    execute: async ({ words }) => {
      const list = await site.blogPosts().listBlogs({ search: words, perPage: 5 })
      return { posts: list.results.map(({ title, url }) => ({ title, url })) }
    },
  })

  const findImages = tool({
    name: 'find_library_images',
    description:
      "Images already in the site's library that match a few words. Use one as featuredImage by its slug, with featuredImageAlt.",
    inputSchema: z.object({
      words: z.string().min(2).describe('What the picture should show, e.g. "storefront"'),
    }),
    execute: async ({ words }) => {
      const images = await site.fetchskiMedia().listMediaImages({ q: words, limit: 5 })
      return {
        images: images.results.map(({ slug, title, alt }) => ({ slug, title, alt })),
      }
    },
  })

  const checkPost = tool({
    name: 'check_post',
    description:
      'Check a whole post (front matter and body) against the site, without saving it. Fix every error it names, then check again.',
    inputSchema: z.object({
      markdown: z
        .string()
        .min(1)
        .describe('The whole post: front matter between --- lines, then the body'),
    }),
    execute: async ({ markdown }) => {
      const result = await site.blogPosts().validateBlogImport({ markdown })
      return { valid: result.valid, errors: result.errors.map((error) => error.message) }
    },
  })

  const saveDraft = tool({
    name: 'save_draft',
    description:
      'Save the checked post on the site as an unpublished draft. Call it once, after check_post says valid.',
    inputSchema: z.object({
      markdown: z.string().min(1).describe('The same post check_post passed'),
    }),
    execute: async ({ markdown }) => {
      if (run.draft) {
        return { error: `Already saved as "${run.draft.title}". Stop here.` }
      }
      run.draft = await site.blogPosts().importBlogPost({ markdown, publish: false, ai: true })
      return { saved: true, title: run.draft.title }
    },
  })

  return [getPostTemplate, listAuthors, findRelatedPosts, findImages, checkPost, saveDraft] as const
}
```

## 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.

```mermaid theme={"dark"}
flowchart LR
    A[Voice memo<br/>transcript] --> B[node ace<br/>post:from-notes]
    B --> C[Agent reads the template,<br/>authors, related posts,<br/>library images]
    C --> D[Agent writes the post<br/>and checks it]
    D -- errors --> D
    D -- valid --> E[Saved as a draft]
    E --> F[Preview link +<br/>review queue]
    F --> G[You read it]
    G -- publish --> H[Live on the site]
```

> [!NOTE]
>
> **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](https://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).

**1. 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**.

**2. Name it**

**Label**: `Voice memo to blog post`.

**3. 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:

| Scope | Why |
| - | - |
| `content:blog:write` | Read the post template, check a post, save it as a draft, make its preview link, send it for review |
| `content:blog:read` | Search your published posts, to link to related ones |
| `content:authors:read` | List the blog's authors, to credit the post |
| `images:read` | Search the image library for a featured image |
| `content:images:read` | Use a library image in the post, by its slug |

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.

**4. Pick the signing mode**

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

**5. 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

**1. Create the app**

```bash Terminal theme={"dark"}
npm create adonisjs@latest memo-to-post -- --kit=api
cd memo-to-post
```

**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`.

**2. 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:

```ini .npmrc theme={"dark"} theme={"dark"}
@fetchski:registry=https://acme.example/api/v1/sdk/npm/
//acme.example/api/v1/sdk/npm/:_authToken=fsk_acme_…
```

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.

> [!NOTE]
>
> **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](https://docs.fetchski-attract.com/authentication.md)).

Then keep the file out of git. Add this line to the project's `.gitignore` (make the file if
there isn't one):

```text .gitignore theme={"dark"} theme={"dark"}
.npmrc
```

> [!WARNING]
>
> **`.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`.

**3. Install the four packages**

In a terminal, in the same folder as the `.npmrc`, run the line for the package manager your
project uses:

```bash npm theme={"dark"} theme={"dark"}
npm install @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

```bash pnpm theme={"dark"} theme={"dark"}
pnpm add @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

```bash bun theme={"dark"} theme={"dark"}
bun add @fetchski/attract-cms-adonis @fetchski/attract-cms-sdk @openrouter/agent zod
```

> [!TIP]
>
> **`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.

**4. Point the app at your site**

```bash Terminal theme={"dark"}
node ace configure @fetchski/attract-cms-adonis
```

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.`

**5. Add the model's environment variables**

Open `.env` and add these lines at the end, with your OpenRouter key:

```ini .env theme={"dark"}
# OpenRouter: your key, and the model the agent runs on
OPENROUTER_API_KEY=sk-or-v1-…
OPENROUTER_MODEL=~anthropic/claude-opus-latest
```

Replace `start/env.ts` with this whole file:

```ts start/env.ts theme={"dark"}
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/

import { Env } from '@adonisjs/core/env'

export default await Env.create(new URL('../', import.meta.url), {
  // Node
  NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
  PORT: Env.schema.number(),
  HOST: Env.schema.string({ format: 'host' }),
  LOG_LEVEL: Env.schema.string(),

  // App
  APP_KEY: Env.schema.secret(),
  APP_URL: Env.schema.string({ format: 'url', tld: false }),

  // Session
  SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),

  /*
  |----------------------------------------------------------
  | Variables for configuring @fetchski/attract-cms-adonis
  |----------------------------------------------------------
  */
  FETCHSKI_ATTRACT_SITE: Env.schema.string(),
  FETCHSKI_ATTRACT_URL: Env.schema.string({ format: 'url', tld: false }),
  FETCHSKI_ATTRACT_API_KEY: Env.schema.secret(),
  FETCHSKI_ATTRACT_SIGNING_SECRET: Env.schema.secret.optional(),

  // OpenRouter
  OPENROUTER_API_KEY: Env.schema.secret(),
  OPENROUTER_MODEL: Env.schema.string(),
})
```

And replace `.env.test` with this whole file:

```ini .env.test theme={"dark"}
SESSION_DRIVER=memory

# Tests fake the site and the model: placeholders only
FETCHSKI_ATTRACT_SITE=acme
FETCHSKI_ATTRACT_URL=https://acme.example
FETCHSKI_ATTRACT_API_KEY=fsk_acme_0a1b2c3d_not-a-real-secret
OPENROUTER_API_KEY=sk-or-v1-not-a-real-key
OPENROUTER_MODEL=test/fake-model
```

**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 `Secret`s, 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.

**6. Gather the model's options**

Create the file:

```ts config/openrouter.ts theme={"dark"}
import env from '#start/env'

/**
 * The model the agents run on, and the limits every run stays inside.
 */
const openrouterConfig = {
  apiKey: env.get('OPENROUTER_API_KEY'),
  model: env.get('OPENROUTER_MODEL'),
  maxSteps: 12, // tool rounds per run, at most
  maxCostUsd: 1, // a run stops once it has spent this much
}

export default openrouterConfig
```

**Why:** `maxSteps` and `maxCostUsd` cap every run, whatever the model does. A post needs five to
eight steps.

**7. Put the model in the container**

```bash Terminal theme={"dark"}
node ace make:provider openrouter --register
```

Then replace the file it made:

```ts providers/openrouter_provider.ts theme={"dark"}
import { OpenRouter } from '@openrouter/agent'
import type { ApplicationService } from '@adonisjs/core/types'
import type openrouterConfig from '#config/openrouter'

export default class OpenrouterProvider {
  constructor(protected app: ApplicationService) {}

  /**
   * One OpenRouter client for the whole app. Tests swap it for a fake model.
   */
  register() {
    this.app.container.singleton(OpenRouter, () => {
      const config = this.app.config.get<typeof openrouterConfig>('openrouter')

      return new OpenRouter({ apiKey: config.apiKey.release() })
    })
  }
}
```

**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`.

**8. 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:

```ts app/services/post_writer_tools.ts theme={"dark"}
import { tool } from '@openrouter/agent'
import type { BlogImportResult, SiteClient } from '@fetchski/attract-cms-sdk'
import { z } from 'zod'

/** What the run saved, for the code around the agent (never the model's word for it). */
export interface PostWriterRun {
  draft: BlogImportResult | null
}

/**
 * The site, as the agent sees it: six tools, each one SDK call. Every
 * write is a draft; nothing here can publish, and no image is created.
 */
export function postWriterTools(site: SiteClient, run: PostWriterRun) {
  const getPostTemplate = tool({
    name: 'get_post_template',
    description:
      "The site's Markdown template for a blog post: every front matter key, and the blog's categories. Read it first.",
    inputSchema: z.object({}),
    execute: async () => {
      const template = await site.blogPosts().getBlogMarkdownTemplate()
      return { template: template.content }
    },
  })

  const listAuthors = tool({
    name: 'list_authors',
    description: "The blog's authors, by name. Credit the post to one with authorName.",
    inputSchema: z.object({}),
    execute: async () => {
      const authors = await site.authors().listAuthorsPick(['title'])
      return { authors: authors.map((author) => author.title) }
    },
  })

  const findRelatedPosts = tool({
    name: 'find_related_posts',
    description:
      'Published posts that match a few words. Link to one or two that a reader of this post would want next.',
    inputSchema: z.object({
      words: z.string().min(2).describe('A few words about the topic, e.g. "spring opening hours"'),
    }),
    execute: async ({ words }) => {
      const list = await site.blogPosts().listBlogs({ search: words, perPage: 5 })
      return { posts: list.results.map(({ title, url }) => ({ title, url })) }
    },
  })

  const findImages = tool({
    name: 'find_library_images',
    description:
      "Images already in the site's library that match a few words. Use one as featuredImage by its slug, with featuredImageAlt.",
    inputSchema: z.object({
      words: z.string().min(2).describe('What the picture should show, e.g. "storefront"'),
    }),
    execute: async ({ words }) => {
      const images = await site.fetchskiMedia().listMediaImages({ q: words, limit: 5 })
      return {
        images: images.results.map(({ slug, title, alt }) => ({ slug, title, alt })),
      }
    },
  })

  const checkPost = tool({
    name: 'check_post',
    description:
      'Check a whole post (front matter and body) against the site, without saving it. Fix every error it names, then check again.',
    inputSchema: z.object({
      markdown: z
        .string()
        .min(1)
        .describe('The whole post: front matter between --- lines, then the body'),
    }),
    execute: async ({ markdown }) => {
      const result = await site.blogPosts().validateBlogImport({ markdown })
      return { valid: result.valid, errors: result.errors.map((error) => error.message) }
    },
  })

  const saveDraft = tool({
    name: 'save_draft',
    description:
      'Save the checked post on the site as an unpublished draft. Call it once, after check_post says valid.',
    inputSchema: z.object({
      markdown: z.string().min(1).describe('The same post check_post passed'),
    }),
    execute: async ({ markdown }) => {
      if (run.draft) {
        return { error: `Already saved as "${run.draft.title}". Stop here.` }
      }
      run.draft = await site.blogPosts().importBlogPost({ markdown, publish: false, ai: true })
      return { saved: true, title: run.draft.title }
    },
  })

  return [getPostTemplate, listAuthors, findRelatedPosts, findImages, checkPost, saveDraft] as const
}
```

**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.

> [card] 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.

> [card] 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.

**9. Write the agent**

```ts app/services/post_writer.ts theme={"dark"}
import { inject } from '@adonisjs/core'
import { maxCost, OpenRouter, stepCountIs } from '@openrouter/agent'
import { SiteClient } from '@fetchski/attract-cms-sdk'
import app from '@adonisjs/core/services/app'
import type openrouterConfig from '#config/openrouter'
import { postWriterTools, type PostWriterRun } from '#services/post_writer_tools'

const INSTRUCTIONS = `You turn a business owner's spoken notes into a blog post for their own website.

- Write in their voice: keep their words, their examples and their opinions. Tidy the
  "ums" and repeats, never their meaning.
- Use only facts from the notes. Never invent a price, a date, a name, a number or a
  promise. If the notes leave something open, leave it out and say so in your reply.
- Read the template first and follow it. Pick a category it lists. Body headings start at ##.
- Credit an author from list_authors only when the notes or the request name one.
- Link to one or two related posts in the body, by their url, only when they really fit.
- A featured image is optional: use only a library image's slug, never a web address.
- check_post, fix what it says, and check again until it is valid. Then save_draft once.
- Finish with two or three plain sentences for the editor: what the post covers, and
  anything they should check before publishing.`

export interface PostWriterResult {
  /** The saved draft, or null when the agent saved nothing. */
  draft: {
    id: string
    title: string
    /** The draft on the site, for anyone with the link, without signing in. */
    previewUrl: string | null
  } | null
  /** The agent's note to the editor, or why it saved nothing. */
  note: string
  costUsd: number
}

@inject()
export class PostWriter {
  constructor(
    protected site: SiteClient,
    protected openrouter: OpenRouter
  ) {}

  /** Has the agent write a draft post from the notes, then shares it and sends it for review. */
  async draftFrom(notes: string, author?: string): Promise<PostWriterResult> {
    const config = app.config.get<typeof openrouterConfig>('openrouter')
    const run: PostWriterRun = { draft: null }

    const result = this.openrouter.callModel({
      model: config.model,
      instructions: INSTRUCTIONS,
      input: author ? `Author: ${author}\n\nThe notes:\n\n${notes}` : `The notes:\n\n${notes}`,
      tools: postWriterTools(this.site, run),
      stopWhen: [
        stepCountIs(config.maxSteps),
        maxCost(config.maxCostUsd),
        () => run.draft !== null, // saved: the agent's work is done
      ],
    })
    const note = await result.getText()
    const usage = await result.getUsage()

    if (!run.draft) {
      return { draft: null, note: note.trim(), costUsd: usage.cost ?? 0 }
    }

    // Code, not the agent, makes the private preview link and puts the draft in the review queue.
    const shared = await this.site.blogPosts().shareBlog({ id: run.draft.id, share: true })
    await this.site.blogPosts().submitBlog({ id: run.draft.id })

    const previewUrl =
      run.draft.url && shared.aposShareKey
        ? `${run.draft.url}?${new URLSearchParams({ aposShareKey: shared.aposShareKey, aposShareId: run.draft.id })}`
        : null

    return {
      draft: { id: run.draft.id, title: run.draft.title, previewUrl },
      note: note.trim(),
      costUsd: usage.cost ?? 0,
    }
  }
}
```

**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.

**10. Add the command**

```bash Terminal theme={"dark"}
node ace make:command post_from_notes
```

Replace the file it made:

```ts commands/post_from_notes.ts theme={"dark"}
import { readFile } from 'node:fs/promises'
import { inject } from '@adonisjs/core'
import { args, BaseCommand, flags } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'
import { PostWriter } from '#services/post_writer'

export default class PostFromNotes extends BaseCommand {
  static commandName = 'post:from-notes'
  static description =
    'Turn a voice memo transcript or rough notes into a draft blog post, ready for review'
  static options: CommandOptions = { startApp: true }

  @args.string({ description: 'The transcript or notes, as a text file' })
  declare file: string

  @flags.string({ description: 'The author to credit, as the blog names them' })
  declare author?: string

  @inject()
  async run(writer: PostWriter) {
    const text = await readFile(this.file, 'utf8')
    const notes = text.trim()
    if (notes.length < 200) {
      this.logger.error(
        `${this.file} is too short to make a post from: give it 200 characters or more`
      )
      this.exitCode = 1
      return
    }

    const { draft, note, costUsd } = await writer.draftFrom(notes, this.author)
    if (!draft) {
      this.logger.error('The agent saved no draft: nothing was written to the site')
      this.logger.log(note)
      this.exitCode = 1
      return
    }

    this.logger.success(`Draft saved and sent for review: "${draft.title}"`)
    this.logger.info(`Preview: ${draft.previewUrl ?? 'open it in the CMS, under Submitted drafts'}`)
    this.logger.info(`Cost: $${costUsd.toFixed(4)}`)
    this.logger.log(note)
  }
}
```

**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`.

**11. Format, lint and type-check**

```bash Terminal theme={"dark"}
npm run format
node ace codegen
npm run lint
npm run typecheck
```

**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

**1. Save a transcript**

Most phone recorder apps and meeting tools show a transcript: copy it into a text file. Make a
folder for them:

```bash Terminal theme={"dark"}
mkdir -p storage/notes
```

Then save the transcript there, or use this one:

```text storage/notes/saturday-mornings.txt theme={"dark"}
Okay, quick one for the blog. Starting in March we're going to open on Saturday mornings, nine to one. A lot of you told us weekdays are hard, you're at work, you can't get here before we close, so this is for you. Saturdays are walk-in only, no booking needed, first come first served. Weekday hours stay exactly the same. And if Saturdays go well we'll look at a late evening on Thursdays too, but no promises yet. Thanks for telling us what you need, honestly that's how we decide this stuff.
```

**2. Run the command**

Name an author exactly as the blog lists them, or leave `--author` out:

```bash Terminal theme={"dark"}
node ace post:from-notes storage/notes/saturday-mornings.txt --author="Jordan Lee"
```

**You should see**, after 20 to 60 seconds (the title, link, cost and note are the agent's, so
yours will read differently):

```text Terminal theme={"dark"}
[ success ] Draft saved and sent for review: "We're opening on Saturday mornings"
[ info ] Preview: https://acme.example/blog/were-opening-on-saturday-mornings?aposShareKey=…&aposShareId=…
[ info ] Cost: $0.2480
A short post announcing walk-in Saturday mornings, 9 to 1, from March. The notes give no exact start date, so add one before publishing.
```

**3. 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

> [accordion] 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.

> [accordion] 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`.

> [accordion] UnauthorizedResponseError: User not found.
>
> OpenRouter refused `OPENROUTER_API_KEY`. Copy the key again from
> [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys); it starts with `sk-or-v1-`.

> [accordion] \[ error ] The agent saved no draft
>
> 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`.

> [accordion] … is too short to make a post from
>
> The file has fewer than 200 characters. A minute of talking is plenty.

> [accordion] Preview: open it in the CMS, under Submitted Drafts
>
> The site saved the draft but sent no address for it. It is in **Submitted Drafts** all the same.

> [accordion] npm install answers 401 Unauthorized
>
> 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.

**1. The fake model**

```ts tests/helpers/fake_model.ts theme={"dark"}
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { OpenRouter } from '@openrouter/agent'

/** One answer from the model: tool calls to make, or its closing text. */
export type FakeTurn =
  | { calls: { name: string; arguments: Record<string, unknown> }[] }
  | { text: string }

/**
 * A stand-in for OpenRouter on 127.0.0.1: it answers each request with the
 * next turn, in the Responses API's own stream format, and keeps every
 * request it was sent. Nothing leaves the machine and nothing is spent.
 */
export async function fakeModel(turns: FakeTurn[]) {
  const requests: Record<string, unknown>[] = []

  const server = createServer(async (request, response) => {
    let body = ''
    for await (const chunk of request) {
      body += chunk
    }
    requests.push(JSON.parse(body) as Record<string, unknown>)

    const turn = turns[requests.length - 1] ?? { text: 'Done.' }
    const output =
      'calls' in turn
        ? turn.calls.map((call, index) => ({
            type: 'function_call',
            id: `fc_${requests.length}_${index}`,
            call_id: `call_${requests.length}_${index}`,
            name: call.name,
            arguments: JSON.stringify(call.arguments),
            status: 'completed',
          }))
        : [
            {
              type: 'message',
              id: `msg_${requests.length}`,
              role: 'assistant',
              status: 'completed',
              content: [{ type: 'output_text', text: turn.text, annotations: [] }],
            },
          ]

    const completed = {
      type: 'response.completed',
      sequence_number: 1,
      response: {
        id: `resp_${requests.length}`,
        object: 'response',
        created_at: 1,
        completed_at: 2,
        model: 'test/fake-model',
        status: 'completed',
        error: null,
        incomplete_details: null,
        instructions: null,
        metadata: {},
        parallel_tool_calls: true,
        frequency_penalty: 0,
        presence_penalty: 0,
        temperature: null,
        top_p: null,
        tools: [],
        tool_choice: 'auto',
        output,
        usage: {
          input_tokens: 1000,
          output_tokens: 200,
          total_tokens: 1200,
          cost: 0.001,
          input_tokens_details: { cached_tokens: 0 },
          output_tokens_details: { reasoning_tokens: 0 },
        },
      },
    }
    response.writeHead(200, { 'content-type': 'text/event-stream' })
    response.end(`event: response.completed\ndata: ${JSON.stringify(completed)}\n\n`)
  })

  await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
  const { port } = server.address() as AddressInfo

  return {
    client: new OpenRouter({ apiKey: 'test', serverURL: `http://127.0.0.1:${port}/api/v1` }),
    requests,
    close: () => new Promise<void>((resolve) => server.close(() => resolve())),
  }
}
```

**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.

**2. The agent's tests**

```ts tests/unit/post_writer.spec.ts theme={"dark"}
import { test } from '@japa/runner'
import app from '@adonisjs/core/services/app'
import { OpenRouter } from '@openrouter/agent'
import { factories, fakeAttract } from '@fetchski/attract-cms-adonis/testing'
import { PostWriter } from '#services/post_writer'
import { fakeModel } from '#tests/helpers/fake_model'

const NOTES = 'So the big news this month is that we are opening on Sundays. '.repeat(5)
const POST = `---
title: We're open on Sundays
category: News
---

## Why Sundays

So many of you asked.
`

test.group('PostWriter', () => {
  test('checks the post, saves one draft, shares it and sends it for review', async ({
    assert,
    swap,
    cleanup,
  }) => {
    const site = fakeAttract({
      'blogPosts.getBlogMarkdownTemplate': { filename: 'post.md', content: '---\ntitle:\n---' },
      'blogPosts.validateBlogImport': { valid: true, errors: [] },
      'blogPosts.importBlogPost': factories.BlogImportResult({
        id: 'post-1:en:draft',
        title: "We're open on Sundays",
        url: 'https://acme.example/blog/open-on-sundays',
        published: false,
      }),
      'blogPosts.shareBlog': factories.Blog({ aposShareKey: 'share-key-1' }),
    })
    const model = await fakeModel([
      {
        calls: [
          { name: 'get_post_template', arguments: {} },
          { name: 'find_related_posts', arguments: { words: 'opening hours' } },
        ],
      },
      { calls: [{ name: 'check_post', arguments: { markdown: POST } }] },
      { calls: [{ name: 'save_draft', arguments: { markdown: POST } }] },
      { text: 'A short post announcing Sunday opening. Check the hours before publishing.' },
    ])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const writer = await app.container.make(PostWriter)
    const { draft, note } = await writer.draftFrom(NOTES, 'Jordan Lee')

    assert.equal(
      draft?.previewUrl,
      'https://acme.example/blog/open-on-sundays?aposShareKey=share-key-1&aposShareId=post-1%3Aen%3Adraft'
    )
    assert.equal(note, 'A short post announcing Sunday opening. Check the hours before publishing.')
    site.assertCalled(
      'blogPosts.importBlogPost',
      (args) => args.publish === false && args.ai === true
    )
    site.assertCalled('blogPosts.listBlogs', (args) => args.search === 'opening hours')
    site.assertCalled(
      'blogPosts.shareBlog',
      (args) => args.id === 'post-1:en:draft' && args.share === true
    )
    site.assertCalled('blogPosts.submitBlog', (args) => args.id === 'post-1:en:draft')
    site.assertNotCalled('blogPosts.publishBlog')
    assert.lengthOf(model.requests, 4)
  })

  test('a post that fails the checks is never saved', async ({ assert, swap, cleanup }) => {
    const site = fakeAttract({
      'blogPosts.validateBlogImport': {
        valid: false,
        errors: [{ message: 'Front matter needs a title.' }],
      },
    })
    const model = await fakeModel([
      { calls: [{ name: 'check_post', arguments: { markdown: '## No title' } }] },
      { text: 'The notes did not give me enough for a title.' },
    ])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)

    const writer = await app.container.make(PostWriter)
    const { draft, note } = await writer.draftFrom(NOTES)

    assert.isNull(draft)
    assert.equal(note, 'The notes did not give me enough for a title.')
    site.assertNotCalled('blogPosts.importBlogPost')
    site.assertNotCalled('blogPosts.submitBlog')
  })
})
```

**3. The command's tests**

```ts tests/unit/post_from_notes.spec.ts theme={"dark"}
import { mkdtemp, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from '@japa/runner'
import ace from '@adonisjs/core/services/ace'
import { OpenRouter } from '@openrouter/agent'
import { fakeAttract } from '@fetchski/attract-cms-adonis/testing'
import PostFromNotes from '../../commands/post_from_notes.js'
import { fakeModel } from '#tests/helpers/fake_model'

/** A notes file in a fresh temporary folder. */
async function notesFile(text: string) {
  const file = join(await mkdtemp(join(tmpdir(), 'notes-')), 'notes.txt')
  await writeFile(file, text)
  return file
}

test.group('post:from-notes', (group) => {
  group.each.setup(() => {
    ace.ui.switchMode('raw')
    return () => ace.ui.switchMode('normal')
  })

  test('notes too short to write from never reach the agent', async ({ swap, cleanup }) => {
    const site = fakeAttract()
    const model = await fakeModel([])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)
    const file = await notesFile('Open Sundays.')

    const command = await ace.create(PostFromNotes, [file])
    await command.exec()

    command.assertFailed()
    command.assertLogMatches(/too short to make a post from/)
    site.assertNothingCalled()
  })

  test('the agent saving nothing fails the command, with its reason', async ({
    assert,
    swap,
    cleanup,
  }) => {
    fakeAttract()
    const model = await fakeModel([{ text: 'These notes are a shopping list, not a post.' }])
    cleanup(() => model.close())
    swap(OpenRouter, model.client)
    const file = await notesFile('Milk, eggs, bread, coffee, paper towels. '.repeat(6))

    const command = await ace.create(PostFromNotes, [file])
    await command.exec()

    command.assertFailed()
    command.assertLog('These notes are a shopping list, not a post.')
    assert.lengthOf(model.requests, 1)
  })
})
```

**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.

**4. Run them**

```bash Terminal theme={"dark"}
npm run format
node ace test
```

**You should see:**

```text Terminal theme={"dark"}
[ info ] loading hooks...
[ info ] generating indexes...
[ info ] codegen: created 5 file(s)
[ info ] booting application to run tests...
✔✔✔✔

 PASSED

Tests  4 passed (4)
```

## Turn it off

> [card] 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.

> [card] Revoke the OpenRouter key
>
> Delete it at [openrouter.ai/settings/keys](https://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.

## Related

> [card] [Import a post from Markdown](https://docs.fetchski-attract.com/adonisjs/reference/blog-posts/import-blog-post.md)
>
> Every front matter key and option `importBlogPost()` takes

> [card] [Testing in AdonisJS](https://docs.fetchski-attract.com/adonisjs/testing.md)
>
> `fakeAttract()` and the assertions

> [card] [Required scopes](https://docs.fetchski-attract.com/scopes.md)
>
> The scope each operation needs

> [card] [Update your service map from a note](https://docs.fetchski-attract.com/recipes/adonis-agent-service-map-from-a-note.md)
>
> The same kind of agent, on your service areas


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.