Skip to main content

stock.search

Search Pexels for stock photography matching a query and download the top N results into the helmdeck artifact store. The downloaded photos chain into every content pack — slides.render, slides.narrate, blog.publish, podcast.generate, hyperframes.render — through the existing *_artifact_key chained inputs, same contract as image.generate. Downstream packs don't care whether the image was generated via fal.ai or photographed via Pexels — the artifact-store key is the only thing they need.

Use this when:

  • You want real photography rather than AI-generated art (corporate decks, customer-facing blog feature images, podcast covers that need to feel "real").
  • Generated images would be over-the-top for the use case.
  • The cost or licensing of generated images is a problem (Pexels is free for commercial use).

Setup prerequisite​

Get a free API key at https://www.pexels.com/api/. Then either:

Option A — env var (simplest)

# deploy/compose/.env.local
HELMDECK_PEXELS_API_KEY=your_pexels_key_here

The env var auto-hydrates into the vault as pexels-key on startup.

Option B — vault entry (preferred for multi-tenant)

curl -X POST http://localhost:3000/api/v1/vault/credentials \
-H "Authorization: Bearer $TOKEN" \
-d '{
"name": "pexels-key",
"type": "api_key",
"host_pattern": "api.pexels.com",
"plaintext": "your_pexels_key_here"
}'

Pexels's default rate limit is 200 requests/hour (free tier). High-volume users can request an override at the API portal.

Inputs​

FieldTypeRequiredDefaultNotes
querystringyes—Search terms. Plain English; Pexels does its own tokenization.
enginestringno"pexels"Closed set; day 1 only "pexels". Future: "unsplash", "pixabay".
countnumberno1Number of results to download. Range 1–4 (mirrors image.generate's cap).
orientationstringno(any)Pexels filter: "landscape" / "portrait" / "square".
sizestringno(any)Minimum-size filter: "large" / "medium" / "small".
colorstringno(any)Color filter. Hex like "#ff0000" or named like "red", "blue", etc.
media_typestringno"photo"Day 1: "photo" only. "video" reserved for follow-up PR.
credentialstringno"pexels-key"Vault credential name override.

Validation​

  • query must be non-empty.
  • engine must be "pexels" day 1. Unknown engines reject as invalid_input.
  • count must be 1–4.
  • orientation / size, when supplied, must be in their closed sets above.
  • media_type must be "photo" day 1.
  • Missing credential rejects as invalid_input with a message pointing at the setup instructions.

Outputs​

FieldTypeNotes
enginestringEcho ("pexels").
artifact_keysarray<string>One key per downloaded photo. Pass these into downstream packs.
resultsarray<object>Per-photo metadata. See below.
query_usedstringEcho of the input (helps the agent debug normalization differences).

Each item in results:

FieldTypeNotes
idnumberPexels photo ID. Stable across the Pexels API surface.
photographerstringPhotographer's name (use in attribution UI).
photographer_urlstringPhotographer's Pexels profile URL.
source_urlstringPexels page for the photo. Link here for attribution.
width / heightnumberPixels.
alt_textstringPexels-provided alt text (use for accessibility).
artifact_keystringSame key that appears in artifact_keys[], duplicated here so callers walking metadata don't have to zip indices.

Examples​

Find one mountain sunrise photo​

curl -X POST http://localhost:3000/api/v1/packs/stock.search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"mountain sunrise"}'
{
"engine": "pexels",
"artifact_keys": ["stock.search/photo-000.jpg"],
"results": [
{
"id": 1545743,
"photographer": "Eberhard Grossgasteiger",
"photographer_url": "https://www.pexels.com/@eberhardgross",
"source_url": "https://www.pexels.com/photo/mountain-1545743",
"width": 1920,
"height": 1080,
"alt_text": "Sunrise over a mountain range",
"artifact_key": "stock.search/photo-000.jpg"
}
],
"query_used": "mountain sunrise"
}

Four portrait photos for a Shorts background​

curl -X POST http://localhost:3000/api/v1/packs/stock.search \
-d '{
"query": "futuristic city night",
"count": 4,
"orientation": "portrait",
"size": "large"
}'

Chain into a blog post's feature image​

# 1. Find the photo.
KEY=$(curl -s -X POST http://localhost:3000/api/v1/packs/stock.search \
-d '{"query":"office collaboration"}' | jq -r .artifact_keys[0])

# 2. Use it as blog.publish's feature_image_artifact_key.
curl -X POST http://localhost:3000/api/v1/packs/blog.publish \
-d "{
\"title\": \"How we ship\",
\"body\": \"...\",
\"format\": \"markdown\",
\"destination\": \"ghost\",
\"feature_image_artifact_key\": \"$KEY\"
}"

Chain into a podcast cover​

KEY=$(curl -s -X POST http://localhost:3000/api/v1/packs/stock.search \
-d '{"query":"vintage microphone", "orientation":"square"}' | jq -r .artifact_keys[0])

curl -X POST http://localhost:3000/api/v1/packs/podcast.generate \
-d "{
\"speakers\": {\"alice\":\"voice-001\"},
\"prompt\": \"5-minute explainer on espresso machines\",
\"model\": \"openrouter/openai/gpt-4o-mini\",
\"cover_image_artifact_key\": \"$KEY\"
}"

Chain into a slide hero​

KEY=$(curl -s -X POST http://localhost:3000/api/v1/packs/stock.search \
-d '{"query":"abstract data visualization", "orientation":"landscape"}' | jq -r .artifact_keys[0])

# Use the artifact key as a slides.render input. The slide-level
# integration depends on which slides field the agent picks (see
# slides/render.md §hero-image-* chained inputs).

Chain into a hyperframes composition​

KEY=$(curl -s -X POST http://localhost:3000/api/v1/packs/stock.search \
-d '{"query":"ocean waves", "orientation":"portrait"}' | jq -r .artifact_keys[0])
URL=$(curl -s http://localhost:3000/api/v1/artifacts/$KEY | jq -r .url)

curl -X POST http://localhost:3000/api/v1/packs/hyperframes.render \
-d "{
\"composition_html\": \"<!DOCTYPE html><html><body style='background: url(\\\"$URL\\\"); width:1080px;height:1920px;'></body></html>\",
\"aspect_ratio\": \"9:16\"
}"

Errors​

CodeWhenRecovery
invalid_inputMissing query; unknown engine; count out of 1–4 range; bad orientation/size; media_type other than "photo"; missing credential; pexels 401/403 (auth)Fix the input or the credential. The error message names the actionable bit.
handler_failedPexels 429 (rate limit), 5xx (upstream), empty result set, photo download failed, photo > 32 MiBRetry with a different query (empty results) or back off (rate limit).
artifact_failedArtifact-store upload failedOperator-level — check the artifact backend's health.

Scope cutoffs (day 1)​

  • Photos only. media_type: "video" reserved for a follow-up PR — Pexels videos are MP4 + thumbnails and need a separate artifact-handling pass.
  • Pexels engine only. engine: "unsplash" and engine: "pixabay" are reserved field values; community PRs welcome.
  • Attribution metadata returned, but auto-credit-injection is the agent's job. The pack surfaces photographer + source URL; how to credit them in the final deliverable (slide footer, blog credits, podcast description) is the agent's call. A future chained-input on the content packs could auto-credit; that's a separate issue.

Licensing notes​

Pexels photos are free for commercial use without attribution required, but the platform encourages crediting photographers and we surface the metadata to make it easy. See https://www.pexels.com/license/ for the full terms — the spirit is "use freely, credit when you can." If you're shipping helmdeck output to a customer-facing surface (a podcast cover, a blog feature image), threading the photographer / source_url into the published artifact's metadata is the polite default.

See also​