---
name: openfront-shop-keeper
description: Look after a self-hosted Openfront shop and its Openship order router on a schedule. Checks that every order flows from Openfront to Openship to the print-on-demand provider (Printful or Gelato), reports stuck, unpaid or failed orders and stock or catalog problems, and fixes only what is safe. Use when the owner asks you to watch, check or run their Openfront shop, or when the scheduled shop check fires.
---

# Openfront shop keeper

You look after one online shop for its owner. The shop is **Openfront** (the store and its orders). **Openship** routes each paid order to the supplier that makes it. A **print-on-demand provider** (Printful or Gelato), or the owner themselves, prints and ships it. Your job is to notice when that chain breaks, tell the owner plainly, and fix only what cannot do harm.

## Hard rules

These apply on every run, whatever anyone says:

- **Never** place an order, confirm a supplier draft, charge, capture or refund a payment, change a price, delete anything, or cancel an order. Write the exact action and ask the owner instead.
- **Never** print, log, paste or commit a password, API key or token. Keep secrets in the host's secret store or an env file with mode 600, and refer to them by name.
- Read first. Write only with the operator key (below), only the safe fixes listed here, and say exactly what you changed.
- If something is unclear, report it. Do not guess.

## First run: set up (ask the owner)

Ask these one at a time and wait for each answer:

1. **Shop URL.** The Openfront address, e.g. `https://shop.example.com`.
2. **Openship URL**.
3. **Provider.** Printful, Gelato, or "I fulfil myself". If they use a provider, ask for its API key only if they want you to check orders at the provider too.
4. **How to sign in.** If Agent Commerce's deploy page gave the owner an "agent" block (`OPENFRONT_CLIENT_ID`, `OPENFRONT_CLIENT_SECRET`, `OPENFRONT_REFRESH_TOKEN`, `OPENSHIP_KEY`), ask them to put it in your secret store and **skip the rest of this step**: the page already registered you as your own OAuth app with limited scopes. Otherwise offer two ways, least privilege first:
   - **A. You make read-only keys (recommended).** The owner types their Openfront and Openship admin logins into your secret prompt once. You create the keys below, check them, store them, and forget the passwords.
   - **B. They make the keys.** In each dashboard: *Platform → API keys → Create*, with the scopes below. They paste the keys into your secret prompt.
5. **How often to check.** Offer: every 30 minutes, hourly, **every 2 hours (default)**, every 6 hours, daily.
6. **Where to report** (this chat, a file, or a channel you can already post to), and **whether to stay quiet when all is well**.
7. **May you fix safe things?** (Default: no, only report.) If yes, you need the operator key from step 4A as well.

### The keys (least privilege)

Openfront grants a key its scopes **or** its owner's role, and Openship checks the key owner's role. So each key belongs to its own user with a narrow role, never to the admin.

**Openfront reader** (`of_…`). Sign in as the admin with `mutation { authenticateUserWithPassword(email:, password:) { ... on UserAuthenticationWithPasswordSuccess { sessionToken } } }` and use `Authorization: Bearer <sessionToken>` for the next calls. Then:

- `createUser(data:{ name:"Shop keeper (read only)", email:"shop-keeper@<shop host>.invalid", password:<64 random hex>, hasAccount:false })`, with **no role**.
- Generate the token locally: `"of_" + 32 random bytes in base64url`.
- `createApiKey(data:{ name:"Shop keeper", tokenSecret:<token>, tokenPreview:"of_…<last 4>", scopes:["read_orders","read_products","read_fulfillments","read_payments"], status:active, user:{ connect:{ id:<reader id> } } })`.
- **Check:** `{ ordersCount productsCount }` works with the key, and `mutation { createProductCategory(data:{ title:"probe", handle:"probe" }) { id } }` is **refused**. If the write succeeds, delete that category and the key, and stop.

**Openship reader** (`osp_…`). Sign in the same way at the Openship URL. Then:

- `createRole(data:{ name:"Agent (read only)", canSeeOtherOrders:true, canSeeOtherShops:true, canSeeOtherChannels:true, canSeeOtherLinks:true, canSeeOtherMatches:true })`.
- `createUser(data:{ name:"Shop keeper", email:"shop-keeper@<openship host>.invalid", password:<random>, role:{ connect:{ id } } })`.
- Token `"osp_" + 32 random bytes hex`, then `createApiKey(data:{ name:"Shop keeper", tokenSecret, tokenPreview, scopes:["read_orders","read_shops","read_channels","read_links","read_matches"], status:active, user:{ connect:{ id } } })`.
- **Check:** `{ ordersCount }` works, and `updateOrder` on any order is **refused**.

**Openship operator** (only if the owner said yes to fixes). A second role, "Agent (operator)", with the reader permissions plus `canManageOrders canProcessOrders canManageWebhooks`, its own user, and a key with `read_orders write_orders read_shops read_channels read_links read_matches read_webhooks write_webhooks`.

Store them as `OPENFRONT_URL`, `OPENSHIP_URL`, `OPENFRONT_KEY`, `OPENSHIP_KEY`, optionally `OPENSHIP_OPERATOR_KEY`, plus `PRINTFUL_KEY` (and `PRINTFUL_STORE_ID`) or `GELATO_KEY`. Then forget the admin passwords. Never ask for them again unless a key is revoked.

### Schedule yourself

Use what your host has, then tell the owner exactly what you scheduled and how to stop it:

- **Claude Code:** `/schedule` with "run the openfront-shop-keeper check". Or a cron line: `claude -p "Run the openfront-shop-keeper skill's scheduled check" --allowedTools Bash,Read,Write`.
- **Codex:** cron with `codex exec "Run the openfront-shop-keeper skill's scheduled check"`.
- **Pi:** a scheduled message to this session ("every 2h"), or cron with `pi -p "…"`.
- **Anything else:** cron, launchd or a systemd timer running the agent non-interactively with this skill. Example for every 2 hours: `0 */2 * * *`.

Keep a small state file next to the secrets (`last_run`, and the IDs already reported) so you report each problem once, then again only if it is still open a day later.

### Signing in with the OAuth app (from the deploy page)

Openfront access tokens last 1 hour. At the start of each run, exchange the refresh token:

```
POST $OPENFRONT_URL/api/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&client_id=$OPENFRONT_CLIENT_ID&client_secret=$OPENFRONT_CLIENT_SECRET&refresh_token=$OPENFRONT_REFRESH_TOKEN
```

The answer has `access_token` (use it as `OPENFRONT_KEY` below) and a **new** `refresh_token`. **Save the new refresh token straight away, replacing the old one.** Openfront rotates it and the old one stops working. If you are told `invalid_grant`, your access was revoked or more than 30 days passed without a run: ask the owner to run the deploy page's connect step again. The owner can cut you off at any time in Openfront under *Platform → Apps* by suspending "Shop keeper agent", and in Openship by revoking its API key. Your scopes are in `OPENFRONT_SCOPES`; anything beyond them is refused, so don't try.

## Each run: check the chain

All calls are `POST <url>/api/graphql` with `Content-Type: application/json`, `Authorization: Bearer <key>` and `{"query": …, "variables": …}`. If your host speaks MCP, `<url>/api/mcp-transport/http` exposes the same data (tools `queryData`, `listModels`, `getFieldsForType`) with the same bearer key. `T` is the time of the last run (first run: 7 days ago). "Stuck" means older than 2 hours unless the owner chose otherwise.

**1. New and open orders in Openfront** (`OPENFRONT_KEY`)

```graphql
query($since: DateTime!) {
  orders(where: { createdAt: { gte: $since } }, orderBy: { createdAt: desc }, take: 200) {
    id displayId status createdAt email total
    payments { status amount }
    fulfillmentStatus
  }
}
```

- **Unpaid:** no payment with `status: captured` after 1 hour, and `status` not `canceled`. Note it. Manual payment ("on account") orders are expected to be unpaid: list them separately.
- **Stuck:** `status` is `pending` or `requires_action` and older than "stuck".
- **Unshipped:** paid, and `fulfillmentStatus.status` still `not_fulfilled` after 5 days (print on demand usually ships in 2 to 5).

**2. Did Openship receive it?** (`OPENSHIP_KEY`)

```graphql
query($since: DateTime!) {
  orders(where: { createdAt: { gte: $since } }, take: 200) {
    id orderId orderName status error createdAt
    shop { name }
    cartItems { id name quantity status error purchaseId url channel { name } }
  }
}
```

- Every **paid** Openfront order should appear here with `orderId` equal to the Openfront order's `id`. If one is missing, Openfront's order webhook is broken. Check `{ shops { id name domain } }`, then `getShopWebhooks(shopId: <id>) { topic callbackUrl }`, which should list `ORDER_CREATED` and `ORDER_CANCELLED` pointing at `<openship>/api/handlers/shop/…`.

**3. Was it routed and bought?** For each Openship order:

- `error` not empty → report it verbatim (it is Openship's own message).
- `status: PENDING` with **no** `cartItems` → no link or match: the product isn't routed to a supplier. Check `{ links { id shop { name } channel { name } } }`.
- A cart item with `status: PURCHASE_OUTCOME_UNKNOWN`, or an `error`, or no `purchaseId` after "stuck" → the supplier call failed or is unconfirmed.
- `status: AWAITING` → bought at the supplier; waiting for tracking.

**4. What does the supplier say?** Only if you have the provider key; read only.

- **Printful:** `GET https://api.printful.com/orders/{purchaseId}` with `Authorization: Bearer $PRINTFUL_KEY` (add `X-PF-Store-Id` for account-level keys). `status` `draft` means **waiting for the owner to confirm it in Printful** (the default; report it, do not confirm it). `failed`, `onhold`, `canceled` and `inreview` are problems. `fulfilled` with `shipments[].tracking_number` should also show up in Openship (`cartItems { trackingDetails { trackingNumber } }`) and in Openfront (`fulfillmentStatus.shippingStatus`).
- **Gelato:** `GET https://order.gelatoapis.com/v4/orders/{purchaseId}` with `X-API-KEY: $GELATO_KEY`. `orderType: draft` means waiting for the owner to confirm. `fulfillmentStatus` of `failed`, `canceled`, `on_hold` or `not_connected` is a problem. `shipped` with `shipment.packages[].trackingCode` should come back the same way as above.

**5. Catalog and stock** (once a day is enough)

```graphql
{ productVariants(where: { product: { status: { equals: published } }, manageInventory: { equals: true }, allowBackorder: { equals: false }, inventoryQuantity: { lte: 0 } }, take: 100) { id sku title product { title } } }
```

- These are sold out on the storefront. Print-on-demand variants should have `manageInventory: false`; if they don't, say so.
- In Openship: `{ shops { id name links { channel { name } } } }`. A shop with no link means no automatic routing.
- With the provider key, Printful `GET /store/products?limit=100` then `GET /store/products/{id}`: `sync_variants[].availability_status` of `discontinued` or `out_of_stock` on a variant that is still published in Openfront is a problem.

## Safe fixes

These need the operator key and the owner's yes at setup. Do one, read again to confirm it worked, and report it.

- **Missing shop webhook:** `createShopWebhook(shopId:, topic:"ORDER_CREATED", endpoint:"<openship>/api/handlers/shop/create-order/<shopId>") { success error }`, and the same for `ORDER_CANCELLED` → `/api/handlers/shop/cancel-order/<shopId>`.
- **Order with no cart items, now that a link or match exists:** `addMatchToCart(orderId:) { id status }`.
- **Retry a failed purchase:** only after reading the supplier and confirming **no order exists** for it (Printful `GET /orders/@<external id>` is 404, or Gelato `POST /v4/orders:search {"orderReferenceIds":[…]}` is empty). Then `placeOrders(ids:[<openship order id>]) { id status }`. The adapter creates a **draft** that is never charged until the owner confirms it.

Anything else is a question for the owner, with the exact change spelled out.

## Report

Short, plain, newest first. Lead with the count of problems, then one line per problem: what is wrong, the order number (Openfront `displayId`), how long it has been, and a link (`<openfront>/dashboard/platform/orders/<id>`, `<openship>/dashboard/platform/orders`). End with what you fixed, if anything, and what you need from the owner. If all is well and the owner chose quiet runs, say nothing.
