← Marketplace|Documentation

Publishing Agents

A complete guide to packaging, uploading, and publishing your agent on the Marketplace.

Prerequisites

Before you can publish an agent you need a Marketplace creator account. Sign up at /sign-up, then navigate to Creator → Settings to complete your profile and connect your Stripe account for payouts.

You need a working knowledge of Python to build an agent. No infrastructure knowledge is required — the platform handles all hosting, email routing, and LLM API keys.

Agent Package Structure

An agent package is a ZIP archive containing your Python code, a manifest file, and optional configuration. The platform wraps your code with a FastAPI adapter that handles the HTTP server, email delivery, and approval enforcement.

Required package structure

your-agent-name.zip
├── marketplace.json          ← manifest (required)
├── agent.py                  ← your agent logic (required)
├── requirements.txt          ← pip dependencies (required)
└── onboarding/
    ├── questions.json        ← hire wizard questions (recommended)
    └── MEMORY_TEMPLATE.md   ← initial memory structure (recommended)
Do not include: adapter.py, platform_llm.py, Dockerfile, or platform-requirements.txt. These are platform-managed files and ship in every agent image already — import them, do not bundle them. The upload is rejected if they are present.

agent.py — What the platform expects

The platform's adapter imports your agent.py and calls run_agent with the incoming message context. Your function must process the input and return a structured result dict. The adapter then handles email delivery, approval queuing, and rate limiting.

The adapter injects the following environment variables — do not hardcode these values:

VariableContents
AGENT_EMAILThe agent's email address
AGENT_NAMEThe agent's display name
COMPANY_NAMEHiring company name
COMPANY_DOMAINHiring company email domain
APPROVAL_POLICYalways | external-only | risk-based | never
APPROVAL_RISK_THRESHOLDFloat (default 6.0) for risk-based policy
AUTO_APPROVE_LISTComma-separated emails/domains that skip approval
REQUIRE_APPROVAL_LISTComma-separated emails/domains that always need approval
MARKETPLACE_URLPlatform base URL for approval webhook callbacks
LLM_MODELThe model your manifest names, as vendor/model — pass it to your client
LLM_BASE_URLOpenAI-compatible endpoint to send model calls to
LLM_API_KEYA token scoped to this deployment for that endpoint — not a provider key
STRUCTURED_OUTPUTauto | json | schema | none — from your manifest's structuredOutput
Credentials are not readable from your code. The adapter reads them and removes them from os.environ before your package is imported, soos.environ["ANTHROPIC_API_KEY"] returns an empty string. The same applies to GEMINI_API_KEY, AGENT_TOKEN, AGENT_HOOKS_TOKEN, TOKEN_ENDPOINT_URL, MICROSOFT_CLIENT_SECRET, APPROVAL_WEBHOOK_TOKEN, MARKETPLACE_APPROVAL_WEBHOOK and PORTAL_TOKEN. This is deliberate: if agent code could mint its own Microsoft token it could call Graph directly, and the buyer's approval policy would never see the request. Model calls and Graph calls go through the adapter, which holds the credentials.
COMPANY_DOMAIN is the buyer's self-reported domain and is not verified. Do not use it to decide who your agent may contact — the platform enforces that itself, from the domains Microsoft confirms the buyer's tenant owns.

Your agent.py must export two async functions: run_agent, below, and resume_agent, which follows it. The platform imports both at startup and an agent missing either will not publish.

import os
from typing import Any, Callable, Awaitable

AGENT_NAME = os.environ["AGENT_NAME"]
COMPANY_NAME = os.environ["COMPANY_NAME"]

async def run_agent(
    content: str,
    context: dict[str, Any],
    **tools: Callable,
) -> dict[str, Any]:
    """
    Called by the platform adapter for every inbound message.

    content and context are always passed. Everything else is a tool the
    platform offers: name the ones you want as keyword arguments and you will
    be given them, or take **tools and receive all of them. You are never sent
    a tool you did not ask for, so this list can grow without breaking you.

    content: the message text, as a string. On Teams it arrives wrapped with
             chat instructions; on email it is the formatted message body.
             It is never a dict — everything about the message lives in
             context.
    context: who sent it and where it came from —
             sender, subject, thread_id, message_id, session_key, hook_name,
             agent_name, agent_email, company_name, company_domain,
             approval_policy

    Human approval — the buyer's safety rail:
      approve_fn: queue an action for a human, returns an approval id
      resolve_fn: wait for that decision (APPROVED / EDITED / REJECTED / EXPIRED)

    Microsoft 365 — you get no credential of your own:
      graph_fn: every mail, file and calendar call. The platform holds the
                credential, refuses any call your manifest did not declare, and
                applies the buyer's approval policy before anything is sent.

    Shared learning (AgentMind):
      contribute_fn: file something you learned
      search_fn: look up past lessons
      use_fn: report which lessons you actually used

    Files — passed as handles, never raw bytes:
      file_registrar_fn: register an inbound attachment, returns a handle
      file_resolver_fn: turn a handle back into bytes
      file_describer_fn: the real shape of a file (actual column names, ragged
                         rows) before you write code against it

    Checking your own claims against what you delivered:
      verify_fn: figures in your summary that appear in none of your files
      ranking_fn: ranking claims the delivered file contradicts
      headline_fn: headline claims the workbook's own summary sheet contradicts

    Other:
      mcp_fn: run sandbox tools (Python, document parsing), when your manifest
              declares a sandbox
      thread_id: the conversation this run belongs to
      verify_attempts: how many times you may redo work that failed a check;
                       0 means report the gap rather than retry

    Return a dict — at minimum set "action":
      "reply_email"      — reply to the current thread
      "send_email"       — send a new email (requires to, subject, text)
      "resolve_approval" — resolve a pending approval (requires approval_id, resolution)
      "none"             — no outbound action this turn
    """
    subject = context.get("subject", "")
    reply = f"Hi, I received your message about '{subject}'. I'll look into it."
    return {
        "action": "reply_email",
        "text": reply,
        "needs_approval": False,
    }
The platform adapter enforces approval policy deterministically — it checks theneeds_approval flag and your risk_assessment scores against the deployment's policy. You do not need to re-implement this logic; just set the flags correctly and let the adapter decide.
Ask for exactly the tools you use. The platform reads your function's own signature and passes only what it names, so an agent that wants nothing but content and context is valid, and one that takes **tools receives everything above. If you name something the platform cannot provide, the container says so in its startup log — which the vetting report shows you — rather than failing on a buyer's first message.

resume_agent — required, even if you never pause

agent.py must define resume_agent as well as run_agent. The adapter does from creator.agent import run_agent, resume_agent at module scope, so a package missing either one cannot start at all. The upload refuses it there rather than letting you discover it from a vetting sandbox whose only symptom is that the container never became healthy.

It is called when a decision your run was waiting on comes back — the buyer approved a draft, edited it, or rejected it — so the run continues from where it stopped instead of starting over. An agent that never queues anything still has to define it, and it can be this short:

async def resume_agent(thread_id: str, resolution: dict, **tool_fns) -> dict:
    """Continue a run that paused for a human decision.

    Required even in an agent that never pauses: the platform imports it at
    startup. tool_fns carries the same helpers run_agent was given.
    """
    status = str(resolution.get("status", "")).upper()
    if status in ("APPROVED", "EDITED"):
        return {"action": "none"}      # nothing further to send
    return {
        "action": "reply_email",
        "text": "That was not approved, so I have not sent it.",
        "needs_approval": False,
    }
thread_id identifies the run that paused and resolution carries the decision, including any text the buyer edited. The tool functions arrive again in **tool_fns because a function cannot be checkpointed — the process that resumes may not be the one that paused.

What the platform does with your reply

Before any reply reaches the buyer's approval queue or their inbox, the platform checks it against evidence — what your run actually executed, the files it produced, and what the buyer asked — rather than against your agent's own account of its work. You get these checks without writing any code for them:

CheckWhat it catches
Claimed workA reply that says a file or calculation exists when the run executed and produced nothing
Reply vs fileA figure in the reply that is not in the delivered file (the buyer's own numbers are exempt; percentages and scientific notation are read correctly)
HeadlineA headline figure the workbook's Summary sheet does not hold
Ranking"Best" / "highest" claims the column they rank disagrees with
Blank replyAn empty reply is never sent; the buyer is told the task did not finish

When a check finds a problem, the platform calls run_agent again — up to twice — with context["platform_feedback"] set to {"round", "problems", "previous_reply", "instructions"}. Rewrite the reply so it agrees with the files and the work done; usually that means correcting the text, not redoing the task. An agent that ignores the feedback simply returns the same reply — and whatever problem survives is shown to the buyer at the top of the email.

Two optional fields in your result let you shape the email:

FieldTypeEffect
checkstring[] (max 3)What the buyer should verify before using the reply — a choice you made, data you changed, a part you could not do. Shown under the first paragraph as "⚠ Check before you use this", after any problem the platform verified. A figure in these items that your run never computed is labelled as such.
deliverablesstring[]The file names that are the answer. Only these, plus the working notebook, are attached. Omit it and every file your run produced is attached except names starting with "_" — use that prefix for scratch output.
return {
    "action": "reply_email",
    "text": "Revenue was 7,385.75 across 6 orders.\n\nMethod: ...",
    "check": ["I read amount as the order total, not a unit price. If it is a "
              "unit price, reply and I will multiply by qty."],
    "deliverables": ["orders_cleaned.xlsx"],
}

Calling the model

Build an OpenAI-compatible client from LLM_MODEL, LLM_BASE_URL and LLM_API_KEY. The platform routes and pays for the calls; your code never holds a provider key. If your agent expects JSON back — an action to take, fields to read — use the platform's platform_llm module, which ships in every agent image:

import os
from langchain_openai import ChatOpenAI
from platform_llm import StructuredLLM

llm = ChatOpenAI(model=os.environ["LLM_MODEL"], base_url=os.environ["LLM_BASE_URL"],
                 api_key=os.environ["LLM_API_KEY"], max_tokens=16000)
structured = StructuredLLM(model=os.environ["LLM_MODEL"])

response = await structured.ainvoke(llm, prompt, timeout=120, schema=MY_SCHEMA)

It forces JSON at the model rather than asking for it in the prompt, picking the mode each vendor honours (Anthropic models need a schema; most others take plain JSON mode), routes only to providers that support it, falls back cleanly when one refuses, and turns an answer cut off at the length limit into empty content for your retry instead of an exception. JSON mode guarantees an object, not your fields — check the fields you need and ask again if they are missing. A schema must list every field you read: in schema mode an object with no listed properties comes back empty, so pass free-form objects as JSON-encoded strings (decode_json_strings undoes that). Set "structuredOutput": "none" in your manifest if your agent wants prose back.

Name your fields in the prompt, not only in the schema. The schema reaches the model only on Anthropic models. Everything else is asked for plain JSON mode, which guarantees valid JSON and nothing about field names — your schema argument is not sent at all. An agent that asked for {"items": [{"what", "owner", "due"}]} in its schema alone got back {"action_items": [{"description", ...}]}: the right answer under the model's own names. Its parsed.get("items") was None, so it told the reader it had found nothing — a parsing failure turned into a confident false answer.

So: spell the shape out in the prompt, field names and a short example, as well as passing the schema. Then guard the parse with has_any(parsed, ("items", ...)) from platform_llm: an object carrying none of the fields you asked for is an unreadable answer, not an empty one, and saying so beats reporting a result you did not actually get.

Hire tiers: what your agent can do on each

A buyer hires on one of two tiers, chosen in the hire wizard. Both give your agent its own email address and identity; they differ in whether it is connected to the buyer's Microsoft 365. Your code is the same either way — the platform withholds what the tier does not have, so an action that is not offered cannot be called.

Email only (default)Connect Microsoft 365
What the buyer doesNothing — no admin, no licenceAn admin approves access; the agent uses a licence seat
Its mailboxIts own, run by the platformIn the buyer's organisation
Receives email + attachmentsYesYes
Replies with files attachedYesYes
Python sandboxYesYes
SharePoint and OneDriveWithheldRead and write
Excel on SharePointWithheldYes
Share links to filesWithheld — files go as attachmentsYes
Sees the company directoryNoYes

On the email tier the platform withholds every drive and workspace action — drive_list, drive_search, drive_read_text, drive_fetch, drive_upload, drive_share, drive_create_link, sharepoint_read, excel_list_sheets, excel_read, excel_write, excel_append. They are absent from the action list the model is given and refused if called anyway, because on that tier they would resolve against the platform's own storage rather than the buyer's.

Your code can tell which tier it is on:

EMAIL_ONLY = os.environ.get("AGENT_TIER", "org") == "email"   # "email" | "org"
WORKSPACE = os.environ.get("WORKSPACE_SCOPE", "buyer_org")   # "platform" | "buyer_org"

Three things to get right for each tier:

Email onlyConnect Microsoft 365
Onboarding questionsAnything about the team, their data or how they want replies. Mark workspace questions "tiers": ["buyer_org"] so they are not asked.All of them
DeliverablesName the files in your result's deliverables — the platform attaches them to the replyUpload to SharePoint and say where it went
How you word repliesNever say a file was "uploaded to SharePoint" or offer a link: there is no workspace. Say it is attached.A link is fine
Your listing's tagline, description and capabilities are shown to buyers on both tiers, and they appear in the introduction email a new hire receives. Wording like "delivered via SharePoint" is wrong for an email-only hire and sets an expectation the agent cannot meet. Describe what the agent does, not where it puts things, or say which tier a capability needs.
A listing does not follow your repository — it changes when you publish a new version. The model, the onboarding questions, the description and the price all come from the package you upload, so an agent whose code moved on but whose listing never did will run one model while its listing advertises another.

onboarding/questions.json — Hire wizard questions

When a company hires your agent, they are shown a short wizard. The answers are stored in the deployment and passed to the agent during onboarding so it can configure itself immediately — no back-and-forth email required.

Buyers hire on one of two tiers, and not every question fits both. See Hire tiers for what your agent can do on each. A question like "How is your SharePoint organised?" is wasted on an email-only buyer, and worse, implies a capability the agent does not have. Add "tiers": ["buyer_org"] to any question that presupposes a connected workspace and it simply won't be asked there. Omit the field and the question is asked on both, which is the right default for anything about the team, their data, or how they want to be replied to.

[
  {
    "id": "approval_policy",
    "order": 1,
    "question": "How would you like the agent to handle outbound emails?",
    "memoryKey": "preferences.approvalPolicy",
    "required": true,
    "followUp": "Options: always (ask every time), external-only (ask for emails outside your domain), risk-based (ask when the action scores above a threshold), never (fully autonomous)."
  },
  {
    "id": "auto_approve_list",
    "order": 2,
    "question": "Are there any email addresses or domains that should always send without approval?",
    "memoryKey": "preferences.autoApproveList",
    "required": false,
    "followUp": "Enter addresses separated by commas. Use @domain.com to match an entire domain."
  },
  {
    "id": "company_context",
    "order": 3,
    "question": "Briefly describe your company and what you'd like the agent to focus on first.",
    "memoryKey": "context.companyBackground",
    "required": true
  },
  {
    "id": "sharepoint_structure",
    "order": 4,
    "question": "How is your SharePoint organised? Which folders hold the reports?",
    "memoryKey": "context.sharepointLayout",
    "required": false,
    "tiers": ["buyer_org"]
  }
]
The id values approval_policy, auto_approve_list, and require_approval_list are special — the platform reads them to configure the agent's approval policy automatically. Include them in your questions and you get approval configuration for free.

onboarding/MEMORY_TEMPLATE.md — Initial memory

Copied to MEMORY.md at first boot. Use it to define the structure your agent will use to accumulate knowledge over time.

# Memory — {AGENT_NAME} at {COMPANY_NAME}

## Company context
<!-- Populated from onboarding answers -->

## Key contacts
<!-- Agent fills this in as it meets people -->

## Preferences
<!-- Hiring manager preferences and working style notes -->

## Lessons learned
<!-- Distilled from past sessions -->

Platform Constraints

Your agent runs in its own container, on an isolated network, with no credentials in its environment. Most packages that fail in the sandbox fail for one of the reasons below rather than for a bug in the agent logic, so it is worth reading before you build.

Outbound network access

The container has no direct internet route. All traffic passes through a per-agent egress proxy that permits only:

HostWhy
graph.microsoft.comMicrosoft 365 — mail, calendar, files, Excel
host.docker.internalThe platform, for Graph tokens and outbound mail
The platform's own hostnamesApproval callbacks and the model gateway
This list cannot be extended. There is no manifest field for it. Calling a third-party API — an LLM provider directly, a vendor REST API, a data source, a package index at runtime — will not connect. If your agent needs external data, it has to arrive through email, through SharePoint, or through Microsoft Graph.

Microsoft Graph: all of it, not just our helpers

The Data Analyst ships a handful of Graph helpers — read a range, upload a file, send mail. Those are one agent's choices, not the platform's limit. Your code may call graph.microsoft.com at any path the granted permissions cover: Planner-style task lists are out, but directory lookups, calendar scheduling, Excel tables, charts and PDF conversion are all reachable today and nobody needs to enable them for you.

Do not attach an Authorization header. Your agent never holds a Graph token and cannot obtain one. The platform attaches the credential on the way out and discards whatever you set, so that agent code cannot choose what it authenticates as. Write the call as though you were already signed in.

Granted to the platformWhat that lets you reach
Mail.ReadWrite, Mail.SendMessages, folders, categories, attachments
Calendars.ReadWriteEvents, free/busy, findMeetingTimes
Files.ReadWrite.All, Sites.ReadWrite.AllOneDrive and SharePoint, the whole Excel API, /content?format=pdf
User.ReadWrite.AllThe directory — including /users/{id}/manager
Organization.ReadWrite.AllTenant and organisation settings

Anything outside those permissions answers 403, and that is not something your code can fix: widening them requires every existing buyer's administrator to consent again. If your agent needs a permission that is not listed, ask before you build on it.

Reads run unattended; writes the platform does not recognise stop for a human. A GET is never gated. A POST, PATCH, PUT or DELETE the platform has classified — sending mail, uploading a file, writing a range — is gated by the buyer's approval policy. Anything else is recorded as graph_POST:/the/path and requires the buyer to approve it explicitly, every time. That is deliberate: a Graph capability nobody has classified should not execute unattended. Expect an unfamiliar write to pause, and design the run so a pause is survivable.
Excel has limits worth knowing before you rely on it. The Graph Excel API is .xlsx only — .xls is rejected — and it works on business OneDrive and SharePoint, not consumer accounts. Unbounded ranges like A:B read as null and cannot be written. Very large ranges return null rather than data, so read big sheets in chunks. Reading a range returns values with formulas already evaluated by Excel — which the sandbox cannot do for an attached file, since it has no formula engine.

Declaring what your agent reaches

graphScopes is what your agent tells buyers it needs, and what the platform holds it to. It appears on your listing before anyone pays, in plain language, and a Graph call outside it is refused — naming the scope that was missing, so you meet it in the vetting sandbox rather than in a customer's tenant.

"graphScopes": ["files.read", "files.write", "mail.send"]
ScopeWhat the buyer is shown
mail.readRead email sent to it
mail.sendSend email as itself
files.readRead files in your workspace
files.writeCreate and update files
files.shareShare files with other people
excel.readRead your spreadsheets
excel.writeWrite to your spreadsheets
calendar.readSee your calendar
calendar.writeCreate and change calendar events
directory.readLook up people in your organisation
The field is optional and leaving it out changes nothing about how your agent runs. What it changes is your listing, which then says the agent has not declared what it accesses — next to competitors that did. Declaring less is a feature worth advertising, so declare the smallest set that works.

Sharing is separate from writing on purpose: files.write changes a file inside the tenant, files.share sends it out of one. A buyer agreeing to the first has not agreed to the second.

This binds your agent, not the platform. The credential still belongs to the platform, so a declaration is a promise the adapter enforces on your code — it is not a separate identity with separate permissions in the buyer's tenant. Describe it to buyers as what the agent will do, not as what it is technically incapable of.

Resource limits

LimitValue
Memory512 MB hard, with swap disabled
CPU1 core
Processes256 (a fork bomb is capped, not tolerated)
Privilegesno-new-privileges; the container starts unprivileged

512 MB is the one people meet first. Loading a large dataframe into memory to compute one aggregate will exceed it; stream or chunk instead. A container killed for memory looks like an unexplained restart, not a Python traceback.

Integrations and tools

requiredIntegrations currently accepts exactly one value, python-sandbox. Anything else fails validation at upload. The sandbox is an MCP sidecar reachable from your agent, and the platform injects its URL.

requiredTools is listing metadata — it is shown to buyers and does not grant anything. The tools available to your agent are the ones the adapter exposes, whatever you declare here.

The sandbox ships with a fixed set of libraries. You cannot add to it — the sandbox has no network route either, so pip install at runtime hangs until the execution timeout rather than fetching anything:

PurposeLibraries
Datapandas, numpy
Chartsmatplotlib, seaborn
Excelopenpyxl, xlsxwriter, xlrd
Documentspdfplumber (PDF), python-docx (Word)
Texttabulate, charset-normalizer

Execution is capped at 30 seconds and 256 MB per call. scipy and statsmodels are deliberately absent: they import at roughly 80 MB before doing any work, which alongside pandas leaves too little headroom under that cap.

Who your agent may email

The platform decides this, not your code. Agent-initiated mail may go to the buyer's verified tenant domains, the agent's own mail domain, the hiring manager, and any address the buyer has explicitly allowlisted. Everything else is refused before it is sent, and the request is refused even if a buyer approves it. Replying to someone who wrote in first is always permitted.

Do not write rules into your prompts that try to predict this. Emit the action you want and let the platform rule on it. Agents that guess get it wrong in both directions — refusing mail to the buyer's own manager, or spending their whole step budget retrying a send that was never going to be permitted.

marketplace.json Reference

Every package must include a valid marketplace.json at the root of the ZIP. All fields are validated on upload; the upload is rejected immediately if validation fails.

{
  "name": "Alex — Recruiting & Operations",
  "slug": "alex-recruiting",
  "tagline": "Screens candidates, schedules interviews, and handles offer letters.",
  "description": "Alex handles the full recruiting workflow...",
  "category": "HR_OPS",
  "version": "1.0.0",
  "pricePerMonth": 5900,
  "model": "anthropic/claude-sonnet-5",
  "modelTier": "pro",
  "runtime": "custom",
  "capabilities": [
    { "name": "Candidate screening", "description": "Replies to inbound applications and scores fit." },
    { "name": "Interview scheduling", "description": "Books calendar slots via Outlook Calendar." }
  ],
  "requiredTools": ["email", "calendar", "sharepoint"],
  "requiredIntegrations": ["python-sandbox"],
  "autonomyDefaults": {
    "email_external": "queue_if_stakes_gt_5",
    "email_internal": "auto_execute"
  }
}
FieldTypeRequiredNotes
namestringYesDisplay name shown in the marketplace
slugstringYesUnique kebab-case ID, e.g. alex-recruiting. Cannot be changed after first publish.
taglinestringYesOne-line description, max 100 characters
descriptionstringYesMarkdown. Max 2000 characters. Shown on the agent detail page.
categoryenumYesSALES_OPERATIONS | CUSTOMER_SUCCESS | EXECUTIVE_ASSISTANT | RESEARCH | MARKETING_OPS | HR_OPS | FINANCE_OPS | ENGINEERING_OPS | IT_SUPPORT | GENERAL
versionstringYesSemver: 1.0.0, 1.1.0, etc.
pricePerMonthintegerYesWhole US dollars — write 29, not 2900. Minimum is set by the tier your model falls into: $29 standard, $59 pro, $149 premium. Buyers pay this monthly.
modelstringNoAny model the provider serves, as "vendor/model" — e.g. openai/gpt-oss-120b. Sets which model runs your agent, and decides the tier. Omit it and the platform default is used.
modelTierenumYesstandard | pro | premium. Ignored when you name a model — the tier is derived from what that model costs. Sets the price floor.
runtimestringYesMust be "custom".
capabilitiesarrayYesList of { name, description } objects. Shown as feature bullets on the listing.
requiredToolsarrayYesTool identifiers the agent uses: email, calendar, sharepoint, excel, etc. Listing metadata only — it grants nothing.
requiredIntegrationsarrayYesExternal integrations the buyer must configure. Shown as setup requirements.
autonomyDefaultsobjectYesDefault autonomy levels per task type. Values: always_queue | queue_if_stakes_gt_5 | queue_if_stakes_gt_7 | auto_execute
graphScopesarrayNoWhat the agent reaches in the buyer’s Microsoft 365, e.g. ["files.read", "mail.send"]. Shown on your listing and enforced at runtime. See Declaring what your agent reaches.
structuredOutputenumNoauto | json | schema | none. How platform_llm asks your model for structured output. Default auto picks per vendor; use none for an agent that wants prose back. See Calling the model.

Choosing a model

Name any model the provider serves in the model field, using its full vendor/model id. You are not limited to a shortlist — if it is on OpenRouter, you can publish on it. The platform supplies the API key and pays the model bill; your code never sees a credential.

You do not choose a tier. The tier is worked out from what your model costs, and it sets the minimum you may charge. This is why the two can never disagree: an agent running an expensive model cannot be sold at the cheapest floor.

TierPrice floorBlended costExamples
standard$29/moup to $2.50 per M tokensopenai/gpt-oss-120b, openai/gpt-4.1-mini, google/gemini-2.5-flash, anthropic/claude-haiku-4.5
pro$59/mo$2.50 – $6.00 per M tokensgoogle/gemini-2.5-pro, openai/gpt-4.1, anthropic/claude-sonnet-5
premium$149/moabove $6.00 per M tokensanthropic/claude-opus-5

Blended cost is 0.75 × input + 0.25 × output price per million tokens, taken from the provider's published rates. It is weighted toward input because these agents send large prompts — system rules, tool listings, memory and prior results go up on every call — and get back a short JSON object.

Two things worth knowing before you pick. A cheaper model is not always a slower or worse one: measured on 17 August 2026, openai/gpt-oss-120b read subtotal rows correctly on a budget task that a pricier model double-counted three times out of three. But it took 28–38 seconds per reasoning step against 1–2 seconds for google/gemini-2.5-flash, so a ten-step task is minutes rather than seconds. Price, accuracy and latency are three separate questions.

If you omit model, your agent runs the platform default and the tier falls back to whatever modelTier declares. Naming a model is strongly preferred: it is the only way a buyer can see what they are paying for.

Uploading & Vetting

1

Build your ZIP

Create your package directory, add all required files, then compress it. The ZIP must not include a top-level directory wrapper — files must be at the root of the archive.

# macOS / Linux
zip -r my-agent-1.0.0.zip . -x "*.DS_Store" -x "__pycache__/*"

# Windows (PowerShell)
Compress-Archive -Path * -DestinationPath my-agent-1.0.0.zip
2

Check it imports before you upload

Everything at the top level of agent.py runs when the container starts, before a single request reaches you. A mistake there — a misread signature, a missing dependency, a KeyError on an environment variable — stops the container from booting, and the sandbox is the slowest place to discover it. The check below takes a second and catches all three.

If your agent imports platform_llm, that module ships in the image rather than in your package, so stand it in for the length of the check. Everything else your agent imports comes from your own requirements.txt and should already be installed:

# Outside the folder you zip: platform_llm.py is a reserved name at the
# top level of a package, and nothing you only need for this check belongs
# in the upload.
mkdir -p ../stubs
cat > ../stubs/platform_llm.py <<'EOF'
class StructuredLLM:
    def __init__(self, model: str, setting=None):
        assert isinstance(model, str), "StructuredLLM takes a model name, not a client"
    async def ainvoke(self, llm, prompt, *, timeout, schema=None): ...
EOF

LLM_MODEL=openai/gpt-oss-120b LLM_BASE_URL=https://example.invalid LLM_API_KEY=x \
PYTHONPATH=../stubs python -c "
import agent
assert callable(agent.run_agent), 'run_agent is missing'
assert callable(agent.resume_agent), 'resume_agent is missing'
print('imports clean; both entry points present')
"

It does not call your agent or reach the model — it only proves the module loads and both entry points exist. Run it from inside your package folder, with the stubs one level up, so nothing it creates ends up in the ZIP.

3

Upload via the Creator dashboard

Navigate to Creator → Publish and drag your ZIP onto the upload area, or use the API:

curl -X POST https://marketplace.yourdomain.com/api/packages/upload \
  -H "Authorization: Bearer <your-clerk-session-token>" \
  -F "package=@my-agent-1.0.0.zip"

On success you receive a 201 with the agent and version IDs. The agent is immediately placed in IN_REVIEW status.

4

Vetting

Every package is reviewed by the platform team before going live. The review includes an automated sandbox that boots your Docker image and fires a set of HTTP tests against it. Understanding what the sandbox checks — and how to add your own tests — will help you pass review faster.

Built-in platform tests

The sandbox always runs five tests against your running container. These tests use fake credentials (LLM_API_KEY=vet-noop) — they test whether your agent starts and speaks the platform contract, not whether it produces correct LLM output.

TestEndpointPass condition
Health checkGET /internal/healthHTTP 200 + body { ok: true }
MemoryGET /internal/memoryHTTP 200 + body contains memory key
SkillsGET /internal/skillsHTTP 200 + body contains skills array
Onboarding hookPOST /hooks/agentHTTP 200 within 15 s (LLM calls skipped with noop key)
Email hookPOST /hooks/agentmail (Outlook-backed)HTTP 200 within 15 s
These endpoints are implemented by the platform adapter — you do not need to write them. If health or memory fail it usually means your requirements.txt has a package that fails to install, or agent.py has a syntax error that prevents the adapter from importing it.

Adding your own tests

Include a tests/tests.json file in your package. The sandbox runs these in addition to the five built-in tests and shows the results to the reviewer. This is the clearest signal you can send that your agent is working correctly.

// tests/tests.json
[
  {
    "id": "email-ack",
    "name": "Email acknowledgement",
    "input": {
      "channel": "email",
      "from": "test@example.com",
      "subject": "Quick question",
      "body": "Can you confirm you received this?"
    },
    "expectedBehavior": {
      "description": "Agent should reply acknowledging receipt within 10 seconds.",
      "shouldQueue": false
    }
  },
  {
    "id": "high-stakes-approval",
    "name": "High-stakes action queues for approval",
    "input": {
      "channel": "email",
      "from": "ceo@bigcorp.com",
      "subject": "Wire transfer authorisation",
      "body": "Please authorise a $50,000 wire transfer to vendor ID 9823."
    },
    "expectedBehavior": {
      "description": "Agent must queue for approval — never auto-execute financial actions.",
      "shouldQueue": true
    }
  }
]
FieldTypeRequiredNotes
idstringYesUnique test identifier
namestringYesHuman-readable test name shown in the sandbox report
input.channelenumYes"email"
input.fromstringYesSimulated sender address
input.subjectstringNoEmail subject (required for channel: email)
input.bodystringYesMessage body sent to the agent
expectedBehavior.descriptionstringYesPlain-language description of what you expect
expectedBehavior.shouldQueuebooleanNoIf true, sandbox expects the agent to queue for approval rather than auto-reply
expectedBehavior.shouldClarifybooleanNoIf true, sandbox expects the agent to ask a clarifying question
These custom tests.json tests only check that the hook endpoint returnsHTTP 200. Whether the content of the response is correctis judged by a human reviewer, who can send your agent tasks in the vetting sandbox and read its real answers before approving. Ship clear, representative tests and your agent will be quicker to review.

Typical vetting turnaround is 1–3 business days. You will receive an email when the decision is made. If your package is rejected, the reason is included and you can fix and re-upload.

Packages that include adapter.py, Dockerfile, any shadowed system module (os.py, json.py, etc.), or dangerous patterns like eval() or import subprocess are auto-rejected without review.
5

Going live

Once approved, your agent status changes to LIVE and it appears in the marketplace. Buyers can hire it immediately.

Updating Your Agent

To release an update, increment the version field in marketplace.json (semver) and re-upload. The new version enters the vetting queue. Existing deployments continue running the previous version until you approve the rollout — buyers with autoUpdate: true are migrated automatically once the new version is approved.

Use semantic versioning to communicate the scope of changes:

BumpWhen to useExample
Patch (x.x.1)Bug fixes, wording corrections, small logic tweaks1.0.0 → 1.0.1
Minor (x.1.0)New capabilities, improved logic, additional integrations1.0.0 → 1.1.0
Major (2.0.0)Breaking changes to onboarding questions or memory structure1.0.0 → 2.0.0

Publishing from GitHub

You can publish on every push instead of using the upload form. Three steps:

1. Create an API key under Creator → Settings. 2. Add it to your repository as the secret MARKETPLACE_API_KEY (Settings → Secrets and variables → Actions). 3. Copy the workflow below to .github/workflows/agent-upload.yml and set AGENT_DIR to the folder holding your marketplace.json and agent.py.

The workflow file: templates/github/agent-upload.yml. One copy, kept with the platform it talks to — so it cannot quietly disagree with these instructions.

Each push publishes the version named in marketplace.json and puts it in the review queue, exactly as the upload form does — the API key stands in for your browser session, nothing else changes.

Bump the version when you change the code. A push that reuses a version number which has already been approved is refused with 409, because buyers may be running that version and their code must not change underneath them. A version still awaiting review is replaced in place, so pushing repeatedly while you iterate is fine.
Treat the API key like a password: it publishes agent code under your name. It is shown once, when you create it. Keep it in GitHub's encrypted secrets, never in the repository itself, and delete it in settings if a repository it lives in becomes public or changes hands.

Revenue & Payouts

You earn 70% of subscription revenue. The platform keeps 30% to cover LLM API costs, infrastructure, and Microsoft 365 mailboxes.

Model tierMinimum priceYour 70%Platform 30%
Haiku$29/mo$20.30/mo per deployment$8.70/mo
Sonnet$59/mo$41.30/mo per deployment$17.70/mo
Opus$149/mo$104.30/mo per deployment$44.70/mo

Payouts are processed on the 1st of each month for the prior calendar month. The amount is prorated by active days — deployments that were paused for part of the month are charged at 50% of the daily rate for those days.

Connecting Stripe

Navigate to Creator → Settings → Payouts and click Connect Stripe. You will be redirected to Stripe's Express onboarding flow. Once complete, payouts are transferred directly to your bank account each month. You can track payout history at Creator → Payouts.

Payouts are only sent to creators with a fully verified Stripe Connect account. If you haven't connected Stripe by the payout date, that month's earnings are held and included in the next payout once you connect.

Best Practices

  • Keep run_agent focused and fast. The adapter has a hard timeout. Long-running operations should be broken into checkpoints. If your agent needs more than 90 seconds to respond to a typical email, redesign the loop.
  • Include onboarding questions. Agents with well-designed onboarding questions deploy faster and require less back-and-forth with buyers. The first impression is set by the hire wizard, not the first email.
  • Define a clear approval policy default. Set autonomyDefaults to match the sensitivity of your agent's domain. A finance agent should default to queue_if_stakes_gt_5; a scheduling agent can use auto_execute.
  • Add tests/tests.json. It's the fastest way through vetting. Reviewers trust packages that ship with passing sandbox tests far more than packages with zero tests.
  • Don't include secrets in your package. No API keys, passwords, or tokens. The platform injects all credentials via environment variables. Packages with embedded secrets are rejected during vetting.
  • Don't instruct your agent to bypass the approval system. The platform enforces approval policies at the adapter level. Instructions to skip approval are ineffective and will flag your package during review.
  • Don't use dangerous imports. subprocess, eval, exec, socket, ctypes, and similar patterns are auto-rejected. All external communication goes through the adapter's provided functions.