What Tagrly is#
A workspace-scoped image catalog. A customer connects a Google Drive or Dropbox folder; every image is analyzed by Claude vision against a structured schema and stored with the resulting fields: focal subject, scene, lighting, time of day, mood, quality, a marketing-graphic flag, what is visible, and whatever else the detected vertical adds.
The API is the agent-facing surface on that catalog. You describe a page;
it returns images from that customer's library. It never pads a result
to hit a count: when the library is thin on a subject the response
carries a gap_signal naming the shots
that are missing.
Tagrly reads photographs a customer already owns. It is not a stock library and it does not generate images. If your user needs a picture that was never taken, the gap signal will say so. That is the honest answer, not a failure.
Calling it without a key#
No key required
POST /api/try/analyze-photo is the one endpoint that takes
no authentication at all. Send a single image as
multipart/form-data under the field name
photo. It runs the same prompt and JSON schema a paying
workspace gets, returns the reading, and persists nothing. No session,
no disk write, no database row.
curl -X POST https://tagrly.com/api/try/analyze-photo \
-F "photo=@kitchen.jpg"
| Field | Meaning |
|---|---|
ok | Always true on a 200. |
vertical | Which vertical the photo classified into. Decides the schema that ran. |
prompt_version | Version tag for the prompt that produced this reading. |
analysis | The structured reading. Field list depends on vertical. |
elapsed_ms | Server-side time for the classify and analysis calls. |
remaining | Reads left in your current hour. |
limit | Reads allowed per hour, so you can pace without guessing. |
Five reads an hour per connection, on a sliding one-hour window. Eight
megabytes per upload, refused on the Content-Length header
before the body is read. Accepted formats: JPEG, PNG, WebP, HEIC, HEIF,
AVIF and GIF. Anything else that happens to decode is rejected by name.
| Status | error | Cause and response |
|---|---|---|
| 400 | no-file | No part named photo. Fix the field name; do not retry as-is. |
| 400 | empty-file | Zero bytes arrived. Re-read the source file. |
| 400 | wrong-type | Decoded, but outside the format allowlist. The message names the format it found. |
| 400 | bad-image | Would not decode. Different file. |
| 413 | too-large | Over 8 MB. Re-export smaller and resend. |
| 429 | rate-limit | Hourly budget spent. retry_after holds the seconds to wait. |
| 500 | server-misconfigured | Server-side. Retrying will not help; report it. |
| 502 | parse-failed · upstream | The model call failed or returned an unusable shape. Retry once, then back off. |
Authentication#
Every other endpoint takes one header:
Authorization: Bearer tagrly_pk_<workspace-slug>_<32 hex chars>
Keys are created by a signed-in human at Settings → API access, by a workspace owner or admin. There is no programmatic endpoint that mints a key. If you are an agent and your user has no key, say so and ask them to create one there; do not guess at a key format or an issuance route.
Keys are workspace-scoped: one key reaches exactly one tenant's catalog. Only the SHA-256 hash is stored, so the visible key is shown once and cannot be recovered. A lost key is replaced, not read back. Revoking a key stops it resolving on the very next request.
Every key grants full read and write access to its workspace.
MCP server#
Everything below is also served over the Model Context Protocol at
https://tagrly.com/mcp (Streamable HTTP, stateless, spec
revision 2025-06-18). Authenticate with the same
Authorization: Bearer tagrly_pk_... header; initialize
and tools/list work without a key, every tools/call requires one.
Six tools map onto the endpoints in this guide:
search_photos (GET /api/search, with compact records),
find_photos (POST /api/find),
get_page_images (POST /api/page),
get_image (GET /api/image/{id}),
log_usage (POST /api/usage) and
list_collections (GET /api/collections). Each tool call is
dispatched through the corresponding HTTP endpoint internally, so the
behavior, scoping, metering and error semantics in this guide apply
verbatim on both surfaces. The two model-curated tools spend one
metered ask each, exactly like their endpoints.
There is no server-push stream: GET /mcp returns 405 to SSE requests, and tool calls complete in the POST that made them. Client setup recipes live at /mcp-server.
The core loop#
For most work the loop is five steps, four of them one call each.
- Decide which section of the page you are filling (hero, gallery, room, detail).
- Call
POST /api/pagewith a plain-English topic for that section. - Render each pick's
hosted_urlwith thealt_rewriteTagrly wrote for it. - Post the rendered
drive_ids to/api/usage. - Pass
exclude_recently_used_dayson the next call, so a long automation never repeats an image.
If you find yourself writing slot definitions by hand, you want
/api/brief. Most calls should be
/api/page: it infers the layout and returns the same
response shape.
Endpoints#
| If you want to… | Call | Key |
|---|---|---|
| Read one photo you upload | POST /api/try/analyze-photo | No |
| Fill a page section from a plain topic | POST /api/page | Yes |
| Define slot shape yourself | POST /api/brief | Yes |
| Check your remaining allowance | GET /api/usage | Yes |
| Log the images you rendered | POST /api/usage | Yes |
| List what was logged lately | GET /api/usage/recent | Yes |
| Run a keyword and facet search | GET /api/search | Yes |
| Get one image's full analysis | GET /api/image/{drive_id} | Yes |
| List named collections | GET /api/collections | Yes |
| Save a set for human review | POST /api/add-to-collection (form-encoded) | Yes |
| Resolve ids to source URLs | GET /api/urls | Yes |
| Get every id matching a search | GET /api/match-ids | Yes |
| List workspaces this key reaches | GET /api/workspaces | Yes |
POST /api/page#
The smallest useful request is one field.
POST /api/page
Authorization: Bearer tagrly_pk_acme_…
Content-Type: application/json
{ "topic": "weekend brunch on our rooftop patio" }
Request fields#
| Field | Default | What it does |
|---|---|---|
topic | n/a | Plain-English description of the section. Required; an empty one returns topic_required. |
h1 | "" | The page's headline. Anchors the alt rewrites and nudges toward images with negative space. |
content | "" | The page's actual text (draft body, listing description). Grounds retrieval and ranking in what the page says, not just the topic; markup stripped and length capped server-side. Diagnostics report the extracted terms. |
vocabulary | {} | Object with extra_terms and teams arrays. Use for words the catalog would not know. |
exclude_image_ids | [] | Ids already used on this page by earlier calls. |
exclude_recently_used_days | 0 | Skips anything logged via /api/usage in the last N days for this workspace. |
include_promo_graphics | false | Set true when branded promo art is the point, e.g. a campaign landing page. |
safety | "safe" | Safety filter mode applied to the candidate pool. |
dry_run | false | Skips model curation. Inspect the candidate pool at zero cost. |
model | claude-haiku-4-5 | Curation model. A larger model buys sharper judgment at higher cost. /api/brief defaults to the dated claude-haiku-4-5-20251001 instead. |
cost_cap_usd | 0.50 | Circuit breaker for one call. Clamped to a hard ceiling of 5.00. |
Response#
{
"ok": true,
"topic": "weekend brunch on our rooftop patio",
"layout": "food-drink",
"slots": {
"hero": { "wanted": 1, "got": 1, "picks": [ … ] },
"product_grid": { "wanted": 6, "got": 6, "picks": [ … ] },
"experience": { "wanted": 3, "got": 3, "picks": [ … ] }
},
"gap_signal": { "fired": false, … },
"diagnostics": { "claude_cost_usd": 0.029, "elapsed_ms": 9021, … }
}
layout and slots_inferred are the
two fields /api/page adds over /api/brief;
everything else is identical between them.
Each slot also reports candidates_considered,
shortlisted and an
on_topic_score_distribution, which together tell you
whether a thin result means a thin library or a narrow topic.
Fields on every pick#
| Field | Use |
|---|---|
drive_id | Stable id. This is what you send to /api/usage and exclude_image_ids. |
hosted_url | Public URL. Render it directly in src. |
thumb_url | Smaller variant for previews and pickers. |
alt_rewrite | Alt text rewritten for this page's topic. null when alt_text_rewrite is off. |
alt_text | The catalog's own alt text, independent of this request. |
on_topic_score | 0–100. Under 50 is tangential; decide deliberately whether to use it. |
quality_score | 0–100, from the analyzer's quality reading. |
prescore · prescore_signals | Pre-curation ranking score and the signals that produced it. |
why_picked | One sentence on why the curator chose it. Good material for a human-facing explanation. |
focal_subject · focal_category | What the image is of. |
scene · mood · shot_type | Setting and framing, for your own grouping. |
Layout detection#
First match wins, and matching happens within the workspace's vertical, a food topic only reaches the food layout in a hospitality workspace.
| When the topic… | Layout | Slots |
|---|---|---|
Names an entity the workspace defined, or passes vocabulary.teams | sports | hero · action_grid · lifestyle |
| Carries food or drink words, in a hospitality vertical | food-drink | hero · product_grid · experience |
| Carries celebration or private-event words | events | hero · moments_grid · details |
| Carries venue, room or space words | venue | hero · tour_grid · people_in_space |
| Matches nothing above | generic | hero · body_grid · detail |
POST /api/brief#
Same pipeline, your slots. Reach for it when the auto layout does not
match your template (a multi-section homepage, an unusual grid, a
template that needs exactly eight images in one band). Every field from
/api/page applies, plus three.
| Field | Default | What it does |
|---|---|---|
slots | n/a | Array of slot objects. Required; an empty array returns slots_required. |
alt_text_rewrite | true | Off returns alt_rewrite: null and skips that part of curation. |
max_candidates_per_slot | 25 | How deep the shortlist goes before curation. Higher costs more. Clamped to 5–60; going over 60 is noted in diagnostics.validation_warnings. |
POST /api/brief
{
"topic": "our outdoor patio brunch",
"h1": "Weekend Brunch on the Patio",
"slots": [
{ "role": "hero", "count": 1,
"must_have": ["focal:food-or-drink"],
"prefer": ["quality>=75", "negative_space"],
"diversity": "none",
"description": "Lead photo, drink and dish together if possible." },
{ "role": "gallery", "count": 8,
"must_have": ["focal:food-or-drink|focal:product-or-object"],
"prefer": ["quality>=70"],
"diversity": "subject+angle",
"description": "Eight distinct dishes. Vary the angles." },
{ "role": "atmosphere", "count": 4,
"must_have": ["people>=2"],
"prefer": ["mood:lively|mood:celebratory"],
"diversity": "subject+angle",
"description": "Group moments on the patio." }
]
}
/api/page, same
picks, same gap signal, same diagnostics.
The slot language#
Two lists per slot. must_have is a hard filter and every
item must pass. prefer is soft scoring: matches earn a
bonus, misses are not excluded. Within one string, alternatives join
with |.
| Predicate | True when |
|---|---|
focal:<a>,<b> | The image's focal category is one of the listed values. |
scene:<a>,<b> | Its scene is in the set, e.g. scene:bar-area,outdoor-patio. |
mood:<a>,<b> | Its mood is in the set. |
people>=N · people>N · people<N | People in frame compares as stated. |
quality>=N | Quality score is at or above N. |
negative_space | The image has clear space for a text overlay. |
no_back_of_head | Neither alt text nor description describes a back-of-head shot. |
pillar:N | The image is tagged into content pillar N. |
jerseys | Sports overlay. Team jerseys were detected. |
team:<name> | The team appears anywhere: jerseys, branded items, a broadcast on a screen. Most permissive. |
team_focal:<name> | The team is in the focal subject, alt text or jerseys. Excludes background-only mentions. |
team_dominant:<name> | Strictest: the team leads the jersey list or the focal subject. |
sport:<name> | The sport is on screen, or appears in the broadcast content or keywords. |
| Strategy | Effect |
|---|---|
none | No rerank. Best-scoring images, in order. Use for single-image slots. |
subject+angle | Round-robin across scene, shot type and focal category. The default, and right for most grids. |
scene | Bucket by scene only. Maximizes location variety. |
shoot | Bucket by source folder. Avoids a grid that is visibly one shoot. |
Usage ledger#
This is what stops a fifty-post automation from using the same hero fifty times. Log what you rendered, then exclude by age on the next call.
POST /api/usage
{
"items": [
{ "drive_id": "1xabcd…",
"page_url": "https://customer.com/brunch",
"page_topic": "weekend brunch",
"slot_role": "hero" }
]
}
items. Only drive_id is required.
| Call | Parameters | Returns |
|---|---|---|
POST /api/usage |
items array, or a single object. Batches over 100 are truncated to 100; entries with a malformed drive_id are skipped. |
{ ok, recorded, skipped } |
GET /api/usage/recent |
days, default 14, max 365. limit, default 100, max 500. |
{ ok, count, days, items, drive_ids } |
drive_ids comes back de-duplicated and sorted, which is
exactly the shape exclude_image_ids expects, so a
recent-use exclusion is one call and no reshaping.
/api/usage inserts a row per item every time and does not
de-duplicate. The same image legitimately used twice on one page writes
two rows, and the freshness filter handles that.
GET /api/search#
The primitive under everything else: keyword and facet search with no
model in the loop, so it is free and fast. Returns
{ count, results } where each result carries the catalog
fields, but not alt_rewrite,
on_topic_score or why_picked, because nothing
curated it.
| Parameter | Accepts |
|---|---|
q | Free-text query across the catalog's indexed fields. |
scene · mood · shot_type · focal_category · editorial_fit | Exact facet values. |
folder | Restrict to one source folder. |
source | drive or dropbox. |
neg_space | yes or no. |
pillar | Integer 1–5. |
team · year | Team name; four-digit year. |
safety | safe (default) or any. |
dupes | hide (default) or show. |
focal | 1 to match against focal subject only. |
limit | Default 24. Clamped to 500. |
sort | Result ordering, e.g. by relevance or date. |
The remaining endpoints#
| Call | Input | Returns |
|---|---|---|
GET /api/image/{drive_id} |
Path id, 5–100 characters of A–Z a–z 0–9 _ - : |
The full analysis payload for one image. 404 when the id is not in this workspace. |
GET /api/collections |
None | Summary of this workspace's named collections. |
POST /api/add-to-collection |
Form-encoded: name, drive_ids as a comma-separated string |
{ collection_id, collection_name, added }. Matches an existing collection by name, case-insensitively, or creates one. |
GET /api/urls |
ids, comma-separated. Over 500 is truncated. |
{ urls }, source URLs for ids present in this workspace. |
GET /api/match-ids |
The same filters /api/search takes |
{ ids, total, capped, cap }. capped tells you the match set was larger than the 500 returned. |
GET /api/workspaces |
None | Workspaces this credential reaches, and which one is active. |
POST /api/add-to-collection is the one endpoint that takes
application/x-www-form-urlencoded rather than JSON, and
drive_ids is a comma-joined string, not an array. A JSON
body returns missing name or drive_ids.
Applied defaults#
These run on every curated call whether you ask or not. Knowing them explains most surprising results.
| Filter | Behavior | Opt out |
|---|---|---|
| Promo graphics | Images the analyzer flagged as posters, social cards or text-overlay marketing assets are excluded. | include_promo_graphics: true |
| Time of day | Detected from the topic. Brunch, breakfast, morning and lunch restrict to daytime; late night and after hours restrict to night. No time signal means no filter. | Word the topic without a time cue |
| Hidden and duplicate | Images you manually hid, or that rank as duplicates of another shot, are always excluded. | None |
| Minors | Images flagged as showing minors are excluded while safety is safe, which is the default. |
safety: "any" |
| Cross-slot dedup | Within one call no image appears in two slots. Slot order is priority order: the hero is reserved before the grid runs. | None |
| Recent use | Off unless asked. Excludes anything logged via /api/usage in the window you name. |
Omit exclude_recently_used_days |
The gap signal#
When the curator cannot fill a slot honestly, it returns fewer images and explains itself rather than padding the count.
"gap_signal": {
"fired": true,
"shortfall_total": 3,
"slots_short": [ { "role": "action_grid", "short_by": 3 } ],
"explanations": [
"[action_grid] Only 3 candidates show team gear as the focal
subject; the rest had it only in branded items."
],
"would_help": [
"Close-up of 3-4 fans in team jerseys at the bar counter",
"Wide shot of a fan crowd raising drinks together"
],
"recommendations": [ { "type": "retag_pass", … } ]
}
would_help reads like a shot brief because that is what
it is for.
| type | Meaning | Reasonable response |
|---|---|---|
retag_pass | Likely lookalikes exist but were tagged too loosely. | Tell the customer a re-scan would probably surface them. |
generate | The library genuinely lacks the shot. | Fall back to generation, or hand the customer the would_help list. |
broaden_topic | The slot rules were too narrow for this library. | Loosen a must_have and call again. |
The curator is built to return fewer images rather than weak ones. If your layout needs a guaranteed count, broaden the topic, lower the quality threshold or generate, but do not quietly fill the gap with whatever came back. That is the decision the gap signal exists to hand you rather than make for you.
Workspaces and verticals#
Every call is scoped to the workspace its key unlocks. A workspace
declares a vertical, which decides both the analyzer overlay applied to
its photographs and the layout family /api/page can reach.
| Vertical | Adds |
|---|---|
universal | The base schema only. The default when nothing more specific fits. |
hospitality-sports | Team markers, jerseys, what is playing on screen. Unlocks the sports layout. |
restaurant-bar | Food and drink detail. Unlocks the food-drink layout. |
wedding-venue | Ceremony phase and wedding-specific subjects. |
real-estate-listing | Room type and property detail. |
ecommerce-product | Product presentation and packshot signals. |
automotive-listing | Vehicle detail and listing angles. |
A workspace can also carry a free-text overlay appended to the analyzer
prompt for that tenant only, and a manual synonyms table for slang the
catalog would never see on its own. Both are configured by the customer.
Your agent does not need to touch either. /api/page
already knows the customer's domain.
Limits, ceilings and cost#
| Limit | Default | Ceiling |
|---|---|---|
| Anonymous photo reads, per hour, per connection | 5 | 5 |
| Anonymous upload size | 8 MB | 8 MB |
/api/search results | 24 | 500 |
/api/usage items per POST | n/a | 100 |
/api/usage/recent lookback, days | 14 | 365 |
/api/usage/recent items | 100 | 500 |
/api/urls ids per call | n/a | 500 |
/api/match-ids ids returned | 500 | 500 |
| Candidates shortlisted per slot | 25 | 60 |
| Model spend per curated call | $0.50 | $5.00 |
Cost is reported, not estimated: every curated response carries
diagnostics.claude_cost_usd for that exact call, alongside
the token counts and the model used. Budget from that number rather
than from a table on a marketing page. Uncurated calls,
/api/search, the usage endpoints, everything under
the remaining endpoints, run no model
and cost nothing.
The spend cap is a circuit breaker rather than a quota. Before curating,
the pipeline estimates the cost of the slots in the request; if that
estimate exceeds cost_cap_usd, curation is skipped and the
slots fall back to deterministic ranking. The response says so in
diagnostics.cost_cap_hit, so a call never fails silently
or bills past its ceiling.
Errors and retries#
| Status | Body | What to do |
|---|---|---|
| 400 | topic_required · slots_required · drive_id_required · items_must_be_list · invalid_json |
A structured error names the field. Fix the request; retrying it unchanged will fail identically. |
| 401 | authentication required |
The key is missing, malformed or revoked. Ask your user for a new one; there is no endpoint that issues one. |
| 404 | n/a | On /api/image/{drive_id}: the id is not in this workspace. Check the key is for the tenant you meant. |
| 500 | error plus a short trace excerpt |
Retry with exponential backoff (1s, 2s, 4s, 8s) and give up after four attempts. |
Read endpoints are idempotent. /api/page and
/api/brief are not: ties in the candidate scores break
non-deterministically, so the same request can return a different
selection. To make a call reproducible, pass an explicit
exclude_image_ids list.
One more shape worth knowing: an anonymous request to a keyed endpoint
does not always return 401. Signed-out browser traffic is redirected to
sign-in before the endpoint runs, so a client that follows redirects can
receive an HTML page with a 200. Send the
Authorization header on every keyed call and the question
never arises.