Integration recipe
pixPLace tools for Claude and OpenAI function calling
Tool definitions for Claude and OpenAI function calling, generated from the OpenAPI spec, plus a small dispatcher that calls the API.
Who it is for
Developers who build an agent with the Claude API or the OpenAI API and want it to use pixPLace as a tool.
What it automates
An agent that uploads media, publishes posts, reads the comment inbox and replies, through tools that come straight from the spec.
Steps
- Take the tool definitions below. Each OpenAPI operation is one tool: its operationId is the tool name and the parameters and body fields are one flat argument object.
- Regenerate them from openapi.json whenever the spec changes instead of editing them by hand. The same conversion gives the Claude format (input_schema) and the OpenAI format (parameters).
- Pass the tools to the model. When it returns a tool call, run it with the dispatcher below and give the status and the body back as the tool result.
- Keep the API key in the dispatcher only. The model never sees it and never needs it.
- Tell the model to send a fresh idempotency_key for each new post and to reuse it when it repeats the same call. createComment has no idempotency_key: the dispatcher never repeats it, and after a timeout, a 5xx or a 429 the model calls listPostComments first.
- When you create the key, tick the `publish` and `read` scopes. A key without one of them gets 403 insufficient_scope on these calls.
Examples
Placeholders: ppk_live_XXXXXXXX is your key. The ids and the image address are samples. The API address is always https://pixplace.space/api/v1.
Claude: tools for the Messages API
[
{
"name": "uploadFromUrl",
"description": "Have the server download a public image or video and store it. The server downloads the URL (https only, no credentials, public hosts only, at most 3 redirects, 25 s in total) and stores it in the portal's bucket.",
"input_schema": {
"type": "object",
"properties": {
"idempotency_key": {
"type": "string",
"pattern": "^[A-Za-z0-9._:-]{1,128}$",
"description": "Makes the request safe to retry. 1–128 characters from A–Z a–z 0–9 . _ : - ; anything else is a 400. A repeat from the same account returns the ORIGINAL result with HTTP 200 and `Idempotent-Replayed: true`, and spends no quota. After a 503 or a timeout retry with the SAME key. Keys are scoped to the account.",
"examples": [
"exec-1001-item-0"
]
},
"url": {
"type": "string",
"description": "Public https URL of an image or video. The server downloads it. No credentials, no custom port; the host must resolve to a public address.",
"format": "uri",
"maxLength": 2048
}
},
"required": [
"url"
]
}
},
{
"name": "createPost",
"description": "Publish a post (image, video or carousel). Creates a post for the owner of the key.",
"input_schema": {
"type": "object",
"properties": {
"idempotency_key": {
"type": "string",
"pattern": "^[A-Za-z0-9._:-]{1,128}$",
"description": "Makes the request safe to retry. 1–128 characters from A–Z a–z 0–9 . _ : - ; anything else is a 400. A repeat from the same account returns the ORIGINAL result with HTTP 200 and `Idempotent-Replayed: true`, and spends no quota. After a 503 or a timeout retry with the SAME key. Keys are scoped to the account.",
"examples": [
"exec-1001-item-0"
]
},
"title": {
"type": "string",
"description": "Post title.",
"minLength": 3,
"maxLength": 200
},
"attachments": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "https URL on an allowed host, no credentials or custom port.",
"format": "uri",
"maxLength": 2048
},
"type": {
"type": "string",
"description": "IMAGE or VIDEO. Decides which host list applies to `url`.",
"enum": [
"IMAGE",
"VIDEO"
]
},
"width": {
"type": "integer",
"description": "Pixels.",
"minimum": 1,
"maximum": 16384
},
"height": {
"type": "integer",
"description": "Pixels.",
"minimum": 1,
"maximum": 16384
},
"altText": {
"type": "string",
"description": "Alternative text.",
"maxLength": 500
},
"prompt": {
"type": "string",
"description": "Prompt of this item; defaults to the post-level `prompt`.",
"maxLength": 2000
},
"thumbnailUrl": {
"type": "string",
"description": "Poster frame: https image URL on an allowed IMAGE host. Useful for VIDEO.",
"format": "uri",
"maxLength": 2048
}
},
"required": [
"url",
"type"
],
"x-allowedHosts": {
"IMAGE": [
"cdn.pixplace.space"
],
"VIDEO": [
"cdn.pixplace.space",
"videos.pexels.com"
]
},
"description": "IMAGE hosts: cdn.pixplace.space. VIDEO hosts: cdn.pixplace.space, videos.pexels.com. Upload the file with POST /api/v1/uploads or POST /api/v1/uploads/from-url — the returned `publicUrl` is on an allowed host. Any other host is a 400."
},
"minItems": 1,
"maxItems": 10,
"description": "Media of the post; the array order is the carousel order. Send this OR the legacy `attachment`, never both."
},
"attachment": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "https URL on an allowed host, no credentials or custom port.",
"format": "uri",
"maxLength": 2048
},
"type": {
"type": "string",
"description": "IMAGE or VIDEO. Decides which host list applies to `url`.",
"enum": [
"IMAGE",
"VIDEO"
]
},
"width": {
"type": "integer",
"description": "Pixels.",
"minimum": 1,
"maximum": 16384
},
"height": {
"type": "integer",
"description": "Pixels.",
"minimum": 1,
"maximum": 16384
},
"altText": {
"type": "string",
"description": "Alternative text.",
"maxLength": 500
},
"prompt": {
"type": "string",
"description": "Prompt of this item; defaults to the post-level `prompt`.",
"maxLength": 2000
},
"thumbnailUrl": {
"type": "string",
"description": "Poster frame: https image URL on an allowed IMAGE host. Useful for VIDEO.",
"format": "uri",
"maxLength": 2048
}
},
"required": [
"url",
"type"
],
"x-allowedHosts": {
"IMAGE": [
"cdn.pixplace.space"
],
"VIDEO": [
"cdn.pixplace.space",
"videos.pexels.com"
]
},
"description": "IMAGE hosts: cdn.pixplace.space. VIDEO hosts: cdn.pixplace.space, videos.pexels.com. Upload the file with POST /api/v1/uploads or POST /api/v1/uploads/from-url — the returned `publicUrl` is on an allowed host. Any other host is a 400."
},
"description": {
"type": "string",
"description": "Post text.",
"maxLength": 2000
},
"prompt": {
"type": "string",
"description": "The generation prompt, shown on the post.",
"maxLength": 2000
},
"deeplinks": {
"type": "array",
"items": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "https only (javascript:, data:, http:, //host are refused), no credentials, no spaces.",
"format": "uri",
"maxLength": 2048
},
"label": {
"type": "string",
"description": "Button text. `disable_image` and `disable_prompt` are reserved.",
"maxLength": 60
},
"iconType": {
"type": "string",
"description": "Icon of the button. Default `link`.",
"enum": [
"link",
"website",
"image",
"video",
"prompt",
"download",
"shop",
"github",
"telegram",
"youtube",
"instagram",
"twitter",
"tiktok",
"discord"
],
"default": "link"
}
},
"required": [
"url"
]
},
"maxItems": 5,
"description": "Link buttons under the post."
},
"seoKeywords": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100
},
"maxItems": 20,
"description": "Trimmed and de-duplicated (case-insensitive)."
},
"categories": {
"type": "array",
"items": {
"type": "string"
},
"maxItems": 20,
"description": "Category slugs from GET /api/v1/categories. An unknown slug is a 400 that names it. If omitted, a content account gets the category of its niche (GET /api/v1/niches) when that exists."
},
"tags": {
"type": "array",
"items": {
"type": "string",
"maxLength": 100
},
"maxItems": 10,
"description": "Tag names; created automatically."
},
"draft": {
"type": "boolean",
"description": "true creates the post UNLISTED (isPublished=false): hidden from feeds, the sitemap and listings, but reachable by URL for a logged-in user. It still counts against the limits."
}
},
"required": [
"title"
]
}
},
{
"name": "listCommentsInbox",
"description": "The inbox: what other people wrote under any of your posts, newest first. Comments (replies included) under ALL your visible posts.",
"input_schema": {
"type": "object",
"properties": {
"since": {
"type": "string",
"format": "date-time",
"description": "ISO date or timestamp (no zone = UTC). Default: 7 days ago; at most 90 days back, otherwise 400.",
"examples": [
"2026-10-23T00:00:00Z"
]
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50,
"description": "Page size, 1–100. Anything else is a 400."
},
"cursor": {
"type": "string",
"description": "The `nextCursor` of the previous page, passed back unchanged. Pagination is by (time, id), not by page number: new items never shift the next page."
}
}
}
},
{
"name": "createComment",
"description": "Write a comment, or a reply to a comment, under one of your own posts — as your account. The author is always the owner of the key.",
"input_schema": {
"type": "object",
"properties": {
"id": {
"type": "string",
"maxLength": 300,
"description": "The `id` or the `slug` of one of YOUR OWN posts. Someone else's post, a missing one and a post hidden by moderation are all 404 (never 403)."
},
"content": {
"type": "string",
"description": "Trimmed text.",
"minLength": 1,
"maxLength": 2000
},
"parentId": {
"type": [
"string",
"null"
],
"description": "Id of a comment of the SAME post (a reply to a reply is fine). null, \"\" or absent = top-level.",
"maxLength": 100
}
},
"required": [
"id",
"content"
]
}
}
]OpenAI: the same tool in the Chat Completions format (listCommentsInbox)
[
{
"type": "function",
"function": {
"name": "listCommentsInbox",
"description": "The inbox: what other people wrote under any of your posts, newest first. Comments (replies included) under ALL your visible posts.",
"parameters": {
"type": "object",
"properties": {
"since": {
"type": "string",
"format": "date-time",
"description": "ISO date or timestamp (no zone = UTC). Default: 7 days ago; at most 90 days back, otherwise 400.",
"examples": [
"2026-10-23T00:00:00Z"
]
},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 100,
"default": 50,
"description": "Page size, 1–100. Anything else is a 400."
},
"cursor": {
"type": "string",
"description": "The `nextCursor` of the previous page, passed back unchanged. Pagination is by (time, id), not by page number: new items never shift the next page."
}
}
}
}
}
]Dispatcher: turns a tool call into an HTTP request
import os
import time
import requests
BASE = "https://pixplace.space/api/v1"
API_KEY = os.environ["PIXPLACE_API_KEY"]
ROUTES = {
"uploadFromUrl": {
"method": "POST",
"path": "/uploads/from-url",
"pathParams": [],
"queryParams": [],
"headerParams": [
{
"name": "Idempotency-Key",
"arg": "idempotency_key"
}
],
"bodyKeys": [
"url"
]
},
"createPost": {
"method": "POST",
"path": "/posts",
"pathParams": [],
"queryParams": [],
"headerParams": [
{
"name": "Idempotency-Key",
"arg": "idempotency_key"
}
],
"bodyKeys": [
"title",
"attachments",
"attachment",
"description",
"prompt",
"deeplinks",
"seoKeywords",
"categories",
"tags",
"draft"
]
},
"listCommentsInbox": {
"method": "GET",
"path": "/comments",
"pathParams": [],
"queryParams": [
"since",
"limit",
"cursor"
],
"headerParams": [],
"bodyKeys": []
},
"createComment": {
"method": "POST",
"path": "/posts/{id}/comments",
"pathParams": [
"id"
],
"queryParams": [],
"headerParams": [],
"bodyKeys": [
"content",
"parentId"
]
}
}
def run_tool(name, args):
route = ROUTES[name]
path = route["path"]
for p in route["pathParams"]:
path = path.replace("{" + p + "}", str(args[p]))
headers = {"Authorization": "Bearer " + API_KEY}
for h in route["headerParams"]:
if h["arg"] in args:
headers[h["name"]] = args[h["arg"]]
query = {k: args[k] for k in route["queryParams"] if k in args}
body = {k: args[k] for k in route["bodyKeys"] if k in args} or None
# repeat only what is safe: reads, and writes that carry an Idempotency-Key.
# A write without one (createComment) is never repeated here: the model checks listPostComments first.
safe_to_retry = route["method"] == "GET" or "Idempotency-Key" in headers
for attempt in range(3 if safe_to_retry else 1):
r = requests.request(route["method"], BASE + path, params=query, json=body, headers=headers, timeout=60)
if safe_to_retry and r.status_code in (429, 503) and attempt < 2:
time.sleep(min(int(r.headers.get("Retry-After", "5")), 60))
continue
return r.status_code, r.text # give both back to the modelCommon errors
| Status | Cause | What to do |
|---|---|---|
| 400 | The model sent an argument the API refuses: a media type other than IMAGE or VIDEO, an empty title, or a URL that is not https. | Return the error text to the model as the tool result. It names the field, and the model usually corrects it on the next turn. |
| 401 | The API key is missing, malformed, revoked or sent in the wrong header. | The dispatcher adds the header, not the model: check API_KEY there. The header must read Authorization: Bearer followed by your ppk_live_ key. Check the key in Settings → Developer. |
| 403 | The key lacks a scope the call needs: the answer is 403 with error.code insufficient_scope and the missing scope in error.required. | Create a new key in Settings → Developer with the `publish` and `read` scopes ticked and use it instead; repeating the call with the same key fails the same way. |
| 404 | The model used an id that is not a post of yours, or the post is hidden. The API never answers 403 for this. | Return the error to the model and let it call listCommentsInbox again to get a valid postId. |
| 429 | A per-minute or per-day limit was reached. The Retry-After header and the retryAfter field of the body say how many seconds to wait. | Wait that long. For uploadFromUrl and createPost repeat with the same Idempotency-Key. createComment has no Idempotency-Key, so do not repeat it blindly: call listPostComments first and send the comment again only if it is not there. Return the wait time to the model, or sleep in the dispatcher as above for the calls that are safe to repeat, but never loop without a pause. |
The full list of statuses, the retry rules and the limits are in Errors and retries.