Norn API· Set up with an agent

Norn APIGet started

Preview

Set up with an agent

Give your coding agent one line, and it adds the Norn API to your project: the credentials, a token helper, a typed client and a check you can run.

Give this to your coding agent

Paste the line into your coding agent, or start your agent with it from your project folder.

Set up the Norn API in this project by following https://docs.native.no/norn/agent-setup.md

Works in any coding agent that can read a web page. If yours cannot, use Copy page on the setup page and paste that in.

  • It adds NATIVE_CLIENT_ID and NATIVE_CLIENT_SECRET to your example env file, empty. You fill them in when Native issues your credentials.
  • It adds a token helper, a small typed client for brands, ideas and feedback, and a check you can run, in your project’s own language and style.
  • It never writes a secret into a file git tracks, and never puts one in browser or mobile code.
  • It does not call the Norn API, since access is not open yet.
  • Where these docs say nothing, it leaves a TODO and tells you, instead of guessing.
  • It asks before it installs a package, and tells you what it changed.

Prefer to do it by hand? The Quickstart goes through the same calls one at a time.

Instructions for your agent

#

If the person asked you to set up the Norn API, follow these steps in order, and keep to these rules throughout:

  • The Norn API is not live yet. Write the code, but make no call to https://auth.native.no or https://api.native.no until the person’s access opens.
  • Use only what these docs state: the endpoints, fields, values and addresses on these pages and on Authentication. When you need something they do not cover, leave a TODO(norn-api) comment saying what is missing, tell the person, and do not guess.
  • For more detail, read llms-full.txt, every page in one file. llms.txt lists the pages, each with a Markdown copy.
  • Ask the person before you install a package the project does not already have. The Norn API needs nothing but an HTTP client.
  1. Look at the project first

    #

    Before you write anything, find out the language and framework, the package manager, where server-side code lives, where environment config lives and how it is loaded, and which HTTP client the project already uses. Write the code in the project’s own language, style and folders. If it already has Native code, for example from the Native API setup, build on that: reuse its credentials and token helper rather than adding a second copy.

    The Norn API is JSON over HTTPS, and there is no SDK. The samples below use TypeScript with fetch and Python with requests. The Python samples need Python 3.10 or later. In another language or client, keep the addresses, the environment variable names and the field names exactly as they are.

    If the project has no server side, such as a browser app, a mobile app or a static site, stop here and tell the person. The client secret must never reach a browser or a mobile app, so the calls need a backend to run from. In a repository with several apps, use the one with a server side, and say which you chose.

  2. Add the credentials

    #

    The Norn API and the Native API share one login: a client ID and secret that Native issues when access opens. Use exactly these names:

    .env.example

    # Native login, shared by the Native API and the Norn API (https://docs.native.no/authentication).
    # Native issues these when your access opens. Never commit real values.
    NATIVE_CLIENT_ID=
    NATIVE_CLIENT_SECRET=
    • Add both names, with empty values, to the project’s example env file. Create .env.example if there is none.
    • Write the empty names into a local env file only if git ignores it and does not track it. Check both: git check-ignore <file> must print the file’s name, and git ls-files <file> must print nothing.
    • If the local env file, such as .env, is tracked, do not write there, and do not edit .gitignore for it: that does not untrack it. Use a file git ignores and the app loads, such as .env.local, and name it in your summary. If you add a file to .gitignore, tell the person.
    • If the project checks its environment in code, add both as optional and server-only, so the app still starts while they are empty.
    • If the names are already there, leave them as they are.
    • Add no NATIVE_ACCESS_TOKEN. The Quickstart reads a token from it to keep its examples short. Your code fetches tokens with the helper in the next step.
  3. Add a token helper

    #

    Follow Authentication: the client-credentials grant at https://auth.native.no/oauth/token, with audience set to https://api.native.no/v1. A token lasts at most an hour. Cache one token per audience, and fetch a new one a minute before expires_in runs out. Calls that arrive while a token is on its way share that request, so a busy backend fetches one token, not one per call.

    The Norn API’s scope is set when access opens, and these docs do not state it yet. Do not guess it. The client in the next step keeps it in a constant marked TODO, and the check stops until it is filled in.

    Put the helper in a server-only module. If the project already has it from the Native API setup, reuse it: one helper serves both APIs.

    const TOKEN_URL = 'https://auth.native.no/oauth/token'
    
    // Cache one token per API and fetch a new one a minute before it runs out.
    // Calls that arrive while a token is on its way share that request.
    const cache = new Map<string, { token: string; expiresAt: number }>()
    const pending = new Map<string, Promise<string>>()
    
    export async function accessToken(audience: string, scope: string): Promise<string> {
      const key = `${audience} ${scope}`
      const hit = cache.get(key)
      if (hit && hit.expiresAt - Date.now() > 60_000) return hit.token
      let request = pending.get(key)
      if (!request) {
        request = requestToken(audience, scope)
          .then((entry) => {
            cache.set(key, entry)
            return entry.token
          })
          .finally(() => pending.delete(key))
        pending.set(key, request)
      }
      return request
    }
    
    async function requestToken(audience: string, scope: string) {
      const res = await fetch(TOKEN_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          grant_type: 'client_credentials',
          client_id: process.env.NATIVE_CLIENT_ID,
          client_secret: process.env.NATIVE_CLIENT_SECRET,
          audience,
          scope,
        }),
      })
      if (!res.ok) throw new Error(`Token request failed: ${res.status}`)
      const body = (await res.json()) as { access_token: string; expires_in: number }
      return { token: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 }
    }
  4. Add a client for the calls

    #

    Put every Norn API call in one server-only module, with a typed function for each. Take the types from the field lists: the brand object, the batch object, the idea object, and the Body list of each call. In Python the functions have the same names in snake_case.

    FunctionCallWhat to know
    createBrandPOST /v1/brandsAnswers at once, with status reading while Norn reads the website.
    getBrandGET /v1/brands/{brand_id}Returns the brand.
    updateBrandPATCH /v1/brands/{brand_id}Send only what changes. Lists such as rules.never are replaced, not merged.
    waitForBrandPolls getBrand every 10 secondsUntil status is ready. failed means Norn could not read the website: create the brand again with name and description.
    createIdeasPOST /v1/ideasAnswers 202 with a batch that is thinking. The brand must be ready, or Norn answers 409 with the code brand_not_ready.
    getBatchGET /v1/ideas/{batch_id}Returns the batch.
    waitForBatchPolls getBatch every 15 secondsUntil status is done, with the ideas in data, best first. On failed, error says why. In Native a batch usually takes two to six minutes.
    sendFeedbackPOST /v1/feedbackAnswers 202 with a receipt. Each request needs a decision, an outcome or both. Never send a reason the person did not give.

    Keep to these rules in the client:

    • Never hold a request open while Norn thinks. Every call answers within seconds, so give each request a 30-second timeout, and ask for a brand or a batch again no more often than every 10 seconds.
    • Stop waiting after a time limit and throw, so a stuck brand or batch ends the job instead of waiting forever. The samples stop after 15 minutes. Run the waiting in a background job, never inside a web request.
    • On an error, throw one that carries the HTTP status and the type, code and message of the error body. On 429, wait as many seconds as Retry-After says before you try again.
    • Ignore fields you do not use. New ones can appear at any time.
    • Add nothing for predictions. They are in development and not part of the first preview.
    • If the project has tests, test the client with HTTP mocked, using the responses shown on Brands and Ideas. A test never calls the real API.
    import { accessToken } from './native-token'
    
    const NORN_API = 'https://api.native.no/v1'
    export const NORN_AUDIENCE = 'https://api.native.no/v1'
    // TODO(norn-api): Native sets the scope when your access opens. The docs do not state it yet.
    export const NORN_SCOPE = ''
    
    // Stop waiting for a brand or a batch after this long. In Native a batch takes 2 to 6 minutes.
    const WAIT_LIMIT_MS = 15 * 60_000
    
    export type Archetype =
      | 'innocent' | 'everyman' | 'hero' | 'outlaw' | 'explorer' | 'creator'
      | 'ruler' | 'magician' | 'lover' | 'caregiver' | 'jester' | 'sage'
    export type Format = 'image' | 'carousel' | 'video' | 'text'
    export type Channel =
      | 'instagram' | 'facebook' | 'linkedin' | 'tiktok' | 'x'
      | 'threads' | 'bluesky' | 'pinterest' | 'youtube' | 'other'
    
    export interface Rules {
      always: string[]
      never: string[]
    }
    
    // The body of POST /v1/brands. An update sends only what changes.
    export interface BrandInput {
      website?: string
      name?: string
      description?: string // up to 2,000 characters
      language?: string // ISO 639-1, such as 'en'
      country?: string // ISO 3166-1, such as 'US'
      archetype?: Archetype
      rules?: Partial<Rules> // up to 20 of each
    }
    
    export interface Brand {
      id: string
      object: 'brand'
      status: 'reading' | 'ready' | 'failed'
      name: string | null
      website: string | null
      language: string | null
      country: string | null
      description: string | null
      offerings: string[]
      archetype: { value: Archetype; rationale: string | null } | null
      rules: Rules
      created_at: string
    }
    
    export interface IdeasInput {
      brand: string // a brand whose status is 'ready'
      count?: number // 1 to 10, default 3
      formats?: Format[]
      focus?: string // up to 2,000 characters
      date?: string // YYYY-MM-DD, up to 45 days ahead
    }
    
    export interface Idea {
      id: string
      object: 'idea'
      rank: number
      title: string
      hook: string
      angle: string
      why_now: string
      format: Format
      sources: {
        type: 'website' | 'review' | 'product' | 'focus'
        title: string
        url: string | null
        quote: string | null
      }[]
      outline: { opening: string; key_points: string[]; call_to_action: string; visual: string }
      prompt: string
    }
    
    export interface IdeaBatch {
      id: string
      object: 'idea_batch'
      status: 'thinking' | 'done' | 'failed'
      model: string
      brand: string
      data: Idea[]
      left_out: string[]
      error: string | null
      created_at: string
      done_at: string | null
    }
    
    export interface FeedbackInput {
      idea: string
      decision?: 'approved' | 'edited' | 'rejected'
      final_text?: string // with 'edited': what was published
      reasons?: ('off_brand' | 'not_relevant' | 'wrong_language' | 'needs_work' | 'other')[]
      comment?: string // with 'rejected': the reason in the person's own words
      outcome?: {
        channel: Channel
        published_at: string
        views: number
        likes?: number
        comments?: number
        shares?: number
        clicks?: number
      }
    }
    
    export interface FeedbackReceipt {
      object: 'feedback'
      id: string
      idea: string
      created_at: string
    }
    
    export interface NornErrorBody {
      type: string
      code?: string
      message: string
      param?: string
    }
    
    export class NornError extends Error {
      status: number
      body: NornErrorBody | null
      retryAfter: number | null
    
      constructor(status: number, body: NornErrorBody | null, retryAfter: number | null) {
        super(body ? `Norn answered ${status} ${body.type}: ${body.message}` : `Norn answered ${status}`)
        this.status = status
        this.body = body
        this.retryAfter = retryAfter
      }
    }
    
    async function norn<T>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: object): Promise<T> {
      const token = await accessToken(NORN_AUDIENCE, NORN_SCOPE)
      const res = await fetch(`${NORN_API}${path}`, {
        method,
        headers: {
          Authorization: `Bearer ${token}`,
          ...(body ? { 'Content-Type': 'application/json' } : {}),
        },
        body: body ? JSON.stringify(body) : undefined,
        // Every call answers within seconds: the slow work runs in the background.
        signal: AbortSignal.timeout(30_000),
      })
      if (!res.ok) {
        const parsed = (await res.json().catch(() => null)) as { error?: NornErrorBody } | null
        const retryAfter = Number(res.headers.get('Retry-After')) || null
        throw new NornError(res.status, parsed?.error ?? null, retryAfter)
      }
      return (await res.json()) as T
    }
    
    export const createBrand = (input: BrandInput) => norn<Brand>('POST', '/brands', input)
    export const getBrand = (brandId: string) => norn<Brand>('GET', `/brands/${brandId}`)
    export const updateBrand = (brandId: string, changes: BrandInput) =>
      norn<Brand>('PATCH', `/brands/${brandId}`, changes)
    export const createIdeas = (input: IdeasInput) => norn<IdeaBatch>('POST', '/ideas', input)
    export const getBatch = (batchId: string) => norn<IdeaBatch>('GET', `/ideas/${batchId}`)
    export const sendFeedback = (input: FeedbackInput) =>
      norn<FeedbackReceipt>('POST', '/feedback', input)
    
    const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
    
    // Asks again while Norn is still working, and gives up after the time limit.
    async function poll<T extends { status: string }>(
      get: () => Promise<T>,
      everyMs: number,
      limitMs: number,
    ): Promise<T> {
      const giveUpAt = Date.now() + limitMs
      let item = await get()
      while (item.status === 'reading' || item.status === 'thinking') {
        if (Date.now() + everyMs > giveUpAt) {
          throw new Error(`Norn is still ${item.status} after ${limitMs / 60_000} minutes`)
        }
        await sleep(everyMs)
        item = await get()
      }
      return item
    }
    
    // Waits until Norn has read the brand's website. Run it in a background job.
    export async function waitForBrand(brandId: string, limitMs = WAIT_LIMIT_MS): Promise<Brand> {
      const brand = await poll(() => getBrand(brandId), 10_000, limitMs)
      // Norn could not read the website: create the brand again with name and description.
      if (brand.status === 'failed') throw new Error(`Norn could not read ${brand.website}`)
      return brand
    }
    
    // Waits until a batch is done. The ideas are in data, best first. Run it in a background job.
    export async function waitForBatch(batchId: string, limitMs = WAIT_LIMIT_MS): Promise<IdeaBatch> {
      const batch = await poll(() => getBatch(batchId), 15_000, limitMs)
      if (batch.status === 'failed') throw new Error(`Norn could not finish the batch: ${batch.error}`)
      return batch
    }
  5. Add a check the person can run

    #

    Add a script the person runs once their credentials are in, wired to the way the project runs scripts. Load the env file the way the app does, so the check reads the same values. The check:

    • Names each missing variable plainly and stops, before any network call. It stops the same way while the scope is still a TODO.
    • Otherwise fetches a token and makes one read-only call: GET /v1/brands/{brand_id}, for a brand id the person passes, or a made-up one. With a made-up id, Norn’s own not_found error body still shows that Norn answered.
    • Passes only on a brand or on Norn’s own error bodies, as the reference defines them: 404 with the type not_found, or 403 with permission_error. Every other error fails the check: a 404 or 403 page can also come from something in front of Norn, or from a wrong address.
    • On a failure, says what failed: the token request’s HTTP status, or Norn’s error. It never prints the secret or the token.
    • Creates, changes and sends nothing: no brand, no ideas, no feedback.
    import { accessToken } from './native-token'
    import { getBrand, NORN_AUDIENCE, NORN_SCOPE, NornError } from './norn-api'
    
    // True only for an error body Norn itself sent. A 404 or 403 page from anything
    // in front of Norn, or from a wrong address, has no such body.
    function nornSaid(err: unknown, status: number, type: string): boolean {
      return err instanceof NornError && err.status === status && err.body?.type === type
    }
    
    async function main(): Promise<number> {
      const missing = ['NATIVE_CLIENT_ID', 'NATIVE_CLIENT_SECRET'].filter((name) => !process.env[name])
      if (missing.length > 0) {
        console.log(`Norn API: ${missing.join(' and ')} not set. Native issues them when your access opens.`)
        return 1
      }
      if (!NORN_SCOPE) {
        console.log('Norn API: NORN_SCOPE is still a TODO. Native sets the scope when your access opens.')
        return 1
      }
      try {
        await accessToken(NORN_AUDIENCE, NORN_SCOPE)
      } catch (err) {
        console.error(`Norn API: no token. ${(err as Error).message}`)
        return 1
      }
      // Read only. With a made-up id, Norn's own not_found error body still shows that Norn answered.
      const brandId = process.argv[2] ?? 'brand_check'
      try {
        const brand = await getBrand(brandId)
        console.log(`Norn API: signed in. Brand ${brand.id} is ${brand.status}.`)
      } catch (err) {
        if (nornSaid(err, 404, 'not_found')) {
          console.log('Norn API: signed in. No brand has that id, which is fine for this check.')
        } else if (nornSaid(err, 403, 'permission_error')) {
          console.log('Norn API: signed in, but these credentials cannot reach that brand.')
        } else {
          // Every other error fails the check.
          console.error(`Norn API check failed: ${(err as Error).message}`)
          return 1
        }
      }
      return 0
    }
    
    main().then((code) => process.exit(code))

    If you can run commands here and the variables are still empty, run it once: it must name both and stop before any network call. Leave the first real run to the person.

  6. Point future sessions at the docs

    #

    If the project has agent instructions, such as AGENTS.md, CLAUDE.md or a rule file in .cursor/rules/, add the line below, with the real path, to an existing general rule: a file or rule that applies to the whole project. Skip this step when none fits, and do not create a new file for it.

    Text

    - Norn API: docs for coding agents at https://docs.native.no/llms.txt. Our client is in <path to the client module>.
  7. Tell the person what changed

    #

    Finish with a short summary for the person:

    • The files you added or changed, and any package you installed with their approval.
    • What to fill in when access opens: NATIVE_CLIENT_ID and NATIVE_CLIENT_SECRET, with the name of the ignored local env file they go in, and the scope in NORN_SCOPE.
    • How to run the check, and that nothing has called the Norn API yet.
    • Every TODO you left, and why.
    • How access opens. The Norn API opens from the waitlist: the person joins it, and Native emails them when their access is ready.

The Norn API is not open yet

Access opens from the waitlist. Join it and we will email you when your access is ready.

Join the waitlist