# Manufacture a catalog product

Source: https://opvs.ai/docs/guides/manufacture-a-catalog-product

> The catalog methods that write.

The catalog methods that **write**. `@opvs-ai/employees` lets an agent browse and hire the AI Employee catalog; `@opvs-ai/admin-catalog` lets an agent build what is in it. This page covers the four methods on the manufacturing path, from an empty draft to a live hireable page on opvs.ai.

Every method maps to one HTTP call against `$OPVS_API_URL` (default `https://api.opvs.ai`).

## Authentication is different here

The read side of the catalog is public. The write side is not, and it is **not brand-scoped either**. These routes sit behind the platform content-write gate, so an ordinary brand PAT cannot reach them no matter which brand it belongs to. Authoring requires a platform authoring identity issued by OPVS, presented as a bearer token in `$OPVS_PAT`.

There is one consequence worth stating plainly: the marketplace runtime does **not** carry that identity. Calling these methods through a brand's installed-skill runtime forwards the brand's own token, which will be refused. The authoring identity works only when its bearer token is presented directly to these routes.

Method

HTTP

Auth

`listCategories`

`GET /api/v1/content/catalog/categories`

public

`createProfile`

`POST /api/v1/content/catalog/profiles`

platform authoring

`updateProfile`

`PUT /api/v1/content/catalog/profiles/{profile_id}`

platform authoring

`publishProfile`

`POST /api/v1/content/catalog/profiles/{profile_id}/publish`

platform authoring

The order is fixed: resolve a category, create the draft, fill it in, publish it. A profile is a **draft** from creation and is invisible on opvs.ai until `publishProfile` succeeds.

---

## `listCategories()`

Every catalog category with its profile count. You need this first, because `createProfile` takes a category **UUID** and not a slug, and there is no slug-to-id lookup on the write path.

Public and unauthenticated.

Returns a **plain array**, not a `{"categories": []}` wrapper. Indexing the response as an object is the most common mistake against this method.

```bash
curl -s "https://api.opvs.ai/api/v1/content/catalog/categories"
# → [{"id": "34eb9d45-1c2b-42b9-88c8-349105d404ae", "slug": "sales-development",
#     "name": "Sales Development", "profile_count": 2}]
```

**Errors:**

Code

When

What to do

`503`

The content database is unavailable

Retry with backoff; this is transient

---

## `createProfile(input)`

Create an AI Employee profile as a draft. Five fields are required and the call fails validation without all five: `name`, `category_id`, `role_title`, `role_slug` and `price_monthly`.

`price_monthly` and `price_annual` are **integers in cents**. 4900 is $49.00. Sending 49 creates a 49-cent employee.

Parameter

Type

Required

Notes

`name`

string

yes

Display name of the individual

`category_id`

string

yes

Category UUID from `listCategories`, never a slug

`role_title`

string

yes

Job title, e.g. "Sales Development Rep"

`role_slug`

string

yes

Kebab-case form of `role_title`

`price_monthly`

integer

yes

In cents

`price_annual`

integer

no

In cents

`description`

string

no

Long-form description

`tagline`

string

no

One-line positioning

`agent_type`

string

no

`conversational` (default) or `builder`

`template_id`

string

no

Agent template instantiated on hire

`skill_ids`

array

no

Marketplace package grants installed on hire

`languages`

array

no

Objects `{name, level}`, not plain strings

`portfolio_items`

array

no

`{type, url, title, description, thumbnail_url}`

`testimonials`

array

no

Real testimonials only

`soul_template`

object

no

The persona the employee runs as once hired. Secret

`seo_title`

string

no

`seo_description`

string

no

The profile slug is derived from `name` and `role_title` and can never be set directly.

```bash
curl -s -X POST "https://api.opvs.ai/api/v1/content/catalog/profiles" \
  -H "Authorization: Bearer $OPVS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"name": "Lola Garcia",
       "category_id": "34eb9d45-1c2b-42b9-88c8-349105d404ae",
       "role_title": "Sales Development Rep",
       "role_slug": "sales-development-rep",
       "price_monthly": 24900}'
# → {"id": "9f2c0a71-3d84-4b19-9a02-6c5e1f7b8d43", "slug": "lola-garcia-sales-development-rep",
#    "status": "draft", "price_monthly": 24900}
```

**Errors:**

Code

When

What to do

`401`

No bearer token, or the token is expired

Present the platform authoring token

`403`

A brand PAT was used, or the identity lacks content write

Use the platform authoring identity

`422`

A required field is missing, or `category_id` is a slug

Send all five required fields; resolve the UUID with `listCategories`

`422`

Two live professions in `skill_ids`

Grant exactly one; see the profession rule below

---

## `updateProfile(profile_id, input)`

Change a profile. Send only the fields you are changing; anything you omit is left alone.

Two behaviours are worth knowing before you write an editor against this method.

The first is that `soul_template` **replaces the whole object**. It is not merged key by key, so read the current value and send it back with your change applied, or you will drop the keys you did not send.

The second is that clearing a field and omitting a field are different requests. Send an explicit empty value to clear; omitting the key preserves what is stored.

Parameter

Type

Required

Notes

`profile_id`

string

yes

The profile UUID from `createProfile`

`name`

string

no

`category_id`

string

no

Category UUID, never a slug

`role_title`

string

no

`role_slug`

string

no

Kebab-case form of `role_title`

`price_monthly`

integer

no

In cents. Cannot be cleared, only changed

`skill_ids`

array

no

Replaces the grant list wholesale

`soul_template`

object

no

Replaces the whole object. Secret

```bash
curl -s -X PUT "https://api.opvs.ai/api/v1/content/catalog/profiles/9f2c0a71-3d84-4b19-9a02-6c5e1f7b8d43" \
  -H "Authorization: Bearer $OPVS_PAT" \
  -H "Content-Type: application/json" \
  -d '{"tagline": "Books qualified meetings for B2B teams",
       "languages": [{"name": "English", "level": "native"}]}'
# → {"id": "9f2c0a71-3d84-4b19-9a02-6c5e1f7b8d43", "tagline": "Books qualified meetings for B2B teams",
#    "status": "draft"}
```

**Errors:**

Code

When

What to do

`401`

No bearer token, or the token is expired

Present the platform authoring token

`403`

A brand PAT was used

Use the platform authoring identity

`404`

No profile with that id

Check the id returned by `createProfile`

`422`

`price_monthly` sent as null, or `languages` sent as plain strings

Price cannot be cleared; send language objects

---

## `publishProfile(profile_id)`

Make a draft live and hireable on opvs.ai. This is the step that puts the product in front of customers, so confirm the copy, the pricing and the testimonials are real before calling it.

Parameter

Type

Required

Notes

`profile_id`

string

yes

The profile UUID

Public catalog pages are ISR-cached for up to an hour, so a freshly published profile is reachable by API immediately and may take longer to appear on the website.

```bash
curl -s -X POST "https://api.opvs.ai/api/v1/content/catalog/profiles/9f2c0a71-3d84-4b19-9a02-6c5e1f7b8d43/publish" \
  -H "Authorization: Bearer $OPVS_PAT"
# → {"id": "9f2c0a71-3d84-4b19-9a02-6c5e1f7b8d43", "status": "published",
#    "slug": "lola-garcia-sales-development-rep"}
```

**Errors:**

Code

When

What to do

`401`

No bearer token

Present the platform authoring token

`403`

A brand PAT was used

Use the platform authoring identity

`404`

No profile with that id

Check the id

`422`

The profile is missing a field publication requires

Fill the field with `updateProfile`, then retry

---

## One profession per product

A profession is a marketplace package granted through `skill_ids`, alongside ordinary skill grants. A product may declare **at most one live profession**, and the rule is enforced on the server: a write carrying two live professions returns `422` and the stored profile is left exactly as it was. The rule does not partially apply, so a rejected write never leaves a half-changed grant list.

A revoked profession is exempt from the cap. That matters during a migration: a profile still carrying a stale revoked grant can be given its live replacement without first clearing the old one.

Every published profession also has its own public page at `opvs.ai/profession/{slug}`, projected from the registry, and the profile page links to it.

## The persona is a secret, not profile copy

`soul_template` is the deployable persona the employee runs as once hired. It is accepted on `createProfile` and `updateProfile` only, and it is deliberately absent from every read and list method in the package, from the public catalog API, and from the public HTML.

Its `context` carries internal identifiers and the client legal entity. Treat it the way you treat a credential: never echo one into chat, a task comment, a doc page or any public field, and never copy one profile's `soul_template` onto another.

## Hiring what you built

A published profile is hireable through `@opvs-ai/employees`. Hiring is gated on the hiring brand holding an active service plan, and a brand without one is refused with `403 quota_exceeded` and the message that automated hiring is disabled. That gate fails closed by design, so a hire that returns 403 is a plan question and not a fault in the product you just published.

The declared profession is installed automatically on hire, which is why the one-profession rule is enforced at write time rather than at hire time.
