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)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:
| Variable | Contents |
|---|---|
| AGENT_EMAIL | The agent's email address |
| AGENT_NAME | The agent's display name |
| COMPANY_NAME | Hiring company name |
| COMPANY_DOMAIN | Hiring company email domain |
| APPROVAL_POLICY | always | external-only | risk-based | never |
| APPROVAL_RISK_THRESHOLD | Float (default 6.0) for risk-based policy |
| AUTO_APPROVE_LIST | Comma-separated emails/domains that skip approval |
| REQUIRE_APPROVAL_LIST | Comma-separated emails/domains that always need approval |
| MARKETPLACE_URL | Platform base URL for approval webhook callbacks |
| LLM_MODEL | The model your manifest names, as vendor/model — pass it to your client |
| LLM_BASE_URL | OpenAI-compatible endpoint to send model calls to |
| LLM_API_KEY | A token scoped to this deployment for that endpoint — not a provider key |
| STRUCTURED_OUTPUT | auto | json | schema | none — from your manifest's structuredOutput |
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,
}needs_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.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:
| Check | What it catches |
|---|---|
| Claimed work | A reply that says a file or calculation exists when the run executed and produced nothing |
| Reply vs file | A 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) |
| Headline | A headline figure the workbook's Summary sheet does not hold |
| Ranking | "Best" / "highest" claims the column they rank disagrees with |
| Blank reply | An 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:
| Field | Type | Effect |
|---|---|---|
| check | string[] (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. |
| deliverables | string[] | 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.
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 does | Nothing — no admin, no licence | An admin approves access; the agent uses a licence seat |
| Its mailbox | Its own, run by the platform | In the buyer's organisation |
| Receives email + attachments | Yes | Yes |
| Replies with files attached | Yes | Yes |
| Python sandbox | Yes | Yes |
| SharePoint and OneDrive | Withheld | Read and write |
| Excel on SharePoint | Withheld | Yes |
| Share links to files | Withheld — files go as attachments | Yes |
| Sees the company directory | No | Yes |
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 only | Connect Microsoft 365 | |
|---|---|---|
| Onboarding questions | Anything about the team, their data or how they want replies. Mark workspace questions "tiers": ["buyer_org"] so they are not asked. | All of them |
| Deliverables | Name the files in your result's deliverables — the platform attaches them to the reply | Upload to SharePoint and say where it went |
| How you word replies | Never say a file was "uploaded to SharePoint" or offer a link: there is no workspace. Say it is attached. | A link is fine |
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"]
}
]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:
| Host | Why |
|---|---|
| graph.microsoft.com | Microsoft 365 — mail, calendar, files, Excel |
| host.docker.internal | The platform, for Graph tokens and outbound mail |
| The platform's own hostnames | Approval callbacks and the model gateway |
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 platform | What that lets you reach |
|---|---|
| Mail.ReadWrite, Mail.Send | Messages, folders, categories, attachments |
| Calendars.ReadWrite | Events, free/busy, findMeetingTimes |
| Files.ReadWrite.All, Sites.ReadWrite.All | OneDrive and SharePoint, the whole Excel API, /content?format=pdf |
| User.ReadWrite.All | The directory — including /users/{id}/manager |
| Organization.ReadWrite.All | Tenant 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.
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..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"]
| Scope | What the buyer is shown |
|---|---|
| mail.read | Read email sent to it |
| mail.send | Send email as itself |
| files.read | Read files in your workspace |
| files.write | Create and update files |
| files.share | Share files with other people |
| excel.read | Read your spreadsheets |
| excel.write | Write to your spreadsheets |
| calendar.read | See your calendar |
| calendar.write | Create and change calendar events |
| directory.read | Look up people in your organisation |
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.
Resource limits
| Limit | Value |
|---|---|
| Memory | 512 MB hard, with swap disabled |
| CPU | 1 core |
| Processes | 256 (a fork bomb is capped, not tolerated) |
| Privileges | no-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:
| Purpose | Libraries |
|---|---|
| Data | pandas, numpy |
| Charts | matplotlib, seaborn |
| Excel | openpyxl, xlsxwriter, xlrd |
| Documents | pdfplumber (PDF), python-docx (Word) |
| Text | tabulate, 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.
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"
}
}| Field | Type | Required | Notes |
|---|---|---|---|
| name | string | Yes | Display name shown in the marketplace |
| slug | string | Yes | Unique kebab-case ID, e.g. alex-recruiting. Cannot be changed after first publish. |
| tagline | string | Yes | One-line description, max 100 characters |
| description | string | Yes | Markdown. Max 2000 characters. Shown on the agent detail page. |
| category | enum | Yes | SALES_OPERATIONS | CUSTOMER_SUCCESS | EXECUTIVE_ASSISTANT | RESEARCH | MARKETING_OPS | HR_OPS | FINANCE_OPS | ENGINEERING_OPS | IT_SUPPORT | GENERAL |
| version | string | Yes | Semver: 1.0.0, 1.1.0, etc. |
| pricePerMonth | integer | Yes | Whole 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. |
| model | string | No | Any 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. |
| modelTier | enum | Yes | standard | pro | premium. Ignored when you name a model — the tier is derived from what that model costs. Sets the price floor. |
| runtime | string | Yes | Must be "custom". |
| capabilities | array | Yes | List of { name, description } objects. Shown as feature bullets on the listing. |
| requiredTools | array | Yes | Tool identifiers the agent uses: email, calendar, sharepoint, excel, etc. Listing metadata only — it grants nothing. |
| requiredIntegrations | array | Yes | External integrations the buyer must configure. Shown as setup requirements. |
| autonomyDefaults | object | Yes | Default autonomy levels per task type. Values: always_queue | queue_if_stakes_gt_5 | queue_if_stakes_gt_7 | auto_execute |
| graphScopes | array | No | What 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. |
| structuredOutput | enum | No | auto | 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.
| Tier | Price floor | Blended cost | Examples |
|---|---|---|---|
| standard | $29/mo | up to $2.50 per M tokens | openai/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 tokens | google/gemini-2.5-pro, openai/gpt-4.1, anthropic/claude-sonnet-5 |
| premium | $149/mo | above $6.00 per M tokens | anthropic/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
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
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.
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.
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.
| Test | Endpoint | Pass condition |
|---|---|---|
| Health check | GET /internal/health | HTTP 200 + body { ok: true } |
| Memory | GET /internal/memory | HTTP 200 + body contains memory key |
| Skills | GET /internal/skills | HTTP 200 + body contains skills array |
| Onboarding hook | POST /hooks/agent | HTTP 200 within 15 s (LLM calls skipped with noop key) |
| Email hook | POST /hooks/agentmail (Outlook-backed) | HTTP 200 within 15 s |
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
}
}
]| Field | Type | Required | Notes |
|---|---|---|---|
| id | string | Yes | Unique test identifier |
| name | string | Yes | Human-readable test name shown in the sandbox report |
| input.channel | enum | Yes | "email" |
| input.from | string | Yes | Simulated sender address |
| input.subject | string | No | Email subject (required for channel: email) |
| input.body | string | Yes | Message body sent to the agent |
| expectedBehavior.description | string | Yes | Plain-language description of what you expect |
| expectedBehavior.shouldQueue | boolean | No | If true, sandbox expects the agent to queue for approval rather than auto-reply |
| expectedBehavior.shouldClarify | boolean | No | If true, sandbox expects the agent to ask a clarifying question |
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.
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.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:
| Bump | When to use | Example |
|---|---|---|
| Patch (x.x.1) | Bug fixes, wording corrections, small logic tweaks | 1.0.0 → 1.0.1 |
| Minor (x.1.0) | New capabilities, improved logic, additional integrations | 1.0.0 → 1.1.0 |
| Major (2.0.0) | Breaking changes to onboarding questions or memory structure | 1.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.
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.Revenue & Payouts
You earn 70% of subscription revenue. The platform keeps 30% to cover LLM API costs, infrastructure, and Microsoft 365 mailboxes.
| Model tier | Minimum price | Your 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.
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
autonomyDefaultsto match the sensitivity of your agent's domain. A finance agent should default toqueue_if_stakes_gt_5; a scheduling agent can useauto_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.