{
  "name": "app-function-services",
  "title": "Function Services - fetch, App Services, Secrets, Env & Logs",
  "description": "What a deployed function can call besides the database: fetch to third-party APIs (fetch_domains, limits), the injected app services (llm, image, tts, email, notify, realtime.publish, embed, ...) and their billing rules, secrets, env vars, console logging",
  "guid": "sk_plat_afsv",
  "category": "App development",
  "requiredTools": [],
  "content": "# Function Services - fetch, App Services, Secrets, Env & Logs\n\nA deployed function gets its capabilities on the second argument, `(ctx, services)`. This skill covers the ones that reach past the app's own database: outbound HTTP, the platform's app services, secrets, env vars and logging. For the function shape, `ctx`, `gipity.yaml` permissions and the deploy/test loop, load [app-development](app-development.md) first; the `db` helper is in [app-database](app-database.md) and the `storage` helper in [app-files-storage](app-files-storage.md).\n\n## External HTTP (via `fetch`)\n\n```js\nexport default async function getWeather(ctx, { fetch }) {\n    const { zip } = ctx.body;\n    const res = await fetch('https://api.example.com/weather?zip=' + zip);\n    return await res.json();\n}\n```\n\n**Limits:** max_fetches 10. 10s timeout. **1MB response limit** - watch this with real third-party APIs; a \"give me everything\" / `?detail=full` query easily blows the cap and the call throws. Ask for the narrowest response (paginate, pick fields, a smaller `mode=`) rather than fetching big and trimming. **To keep a file** (an image, audio, a PDF a provider hands you a URL for), don't `fetch` it: `await storage.importUrl(url)` downloads it server-side, past the 1MB cap, straight into the app's storage and returns the upload object with its `guid` (see [app-files-storage](app-files-storage.md#saving-a-file-from-a-url-webhooks-finished-renders)).\n**Security:** Only declared `fetch_domains` are allowed. Private IPs blocked (SSRF protection).\n**Response shape:** `res` is WHATWG-style - `res.ok` (true only on 2xx), `res.status`, `res.statusText`, `res.headers.get('content-type')`, `await res.text()`, `await res.json()`. The usual guard works: `if (!res.ok) throw new Error(\\`HTTP ${res.status}\\`)`.\n\n> **`fetch` is for third-party APIs only.** Don't `fetch` the platform's own services at `a.gipity.ai` (llm/image/tts/location/...) - the sandbox blocks every `*.gipity.ai` fetch, and `fetch_domains: ['a.gipity.ai']` does **not** unblock it. Call those through the injected app-service capabilities below instead.\n\n**Declare every host the call touches.** `fetch_domains` is per function in `gipity.yaml`; a redirect to another host is re-checked, so list that host too:\n\n```yaml\n- name: get-weather\n  auth: public\n  fetch_domains: [api.example.com]\n```\n\n## Verifying webhook signatures (via `crypto`)\n\nA provider webhook (Stripe, GitHub, Shopify, Slack, Svix/Clerk) signs the raw request body. Recompute the signature with `crypto.hmac(...)` over **`ctx.rawBody`** (never a re-serialized `ctx.body`), read the provider header from `ctx.headers`, and compare with `crypto.timingSafeEqual`. Full recipe, per-provider header shapes and the `crypto` API: [app-development-reference](app-development-reference.md#verifying-webhook-signatures-via-crypto).\n\n## App services from a function (via `llm`, `image`, `tts`, ...)\n\nThe platform's own services are injected as capabilities on the second argument - peers of `db` and `fetch` - so a function calls them server-to-server with no token and no `fetch`:\n\n```js\nexport default async function summarize(ctx, { db, llm }) {\n  const { rows } = await db.query('SELECT body FROM notes WHERE user_guid = $1', [ctx.auth.userGuid]);\n  const { text } = await llm({ model: 'fast', messages: [\n    { role: 'user', content: 'Summarize:\\n' + rows.map(r => r.body).join('\\n') },\n  ]});\n  await db.query('INSERT INTO summaries (user_guid, summary) VALUES ($1, $2)', [ctx.auth.userGuid, text]);\n  return { summary: text };\n}\n```\n\n```yaml\n- name: summarize\n  auth: user\n  tables: ['notes', 'summaries']\n  services: ['llm']      # required - declares which services this function may call\n```\n\n- Available: `llm` · `image` (plus `image.submit()` / `image.get()` / `image.list()` / `image.cancel()` / `image.models()` for queued Gipity GPU models and async generations, see app-image) · `tts` · `sound` · `music` · `video` · `transcribe` · `location.ip()` / `location.geocode()` · `notify({ to, notification })` · `email({ to, subject, text })` · `realtime.publish({ room, channel, type, data })` · `embed(text | text[])`. Each returns the same shape the HTTP service does (`{ text, usage, ... }`, `{ url, ... }`, ...). See `app-llm`, `app-image`, `app-tts`, `app-audio`, `app-video`, `app-location`, `app-notify`, `email`, `app-vector-search` for per-service params.\n- **`llm()` JSON answers**: pass `response_format: { type: 'json_schema', json_schema: { name, schema } }` (models that support it, e.g. OpenAI's and `gipity-qwen3.6-35b`) or `{ type: 'json_object' }` (any model), then `JSON.parse(text)`. The half-price Gipity GPU model can throw `MODEL_WARMING` (retry after about 45 s) or `PROVIDER_BUSY`; see [app-llm](app-llm.md).\n- **`email()`** sends from a deployed app (declare `services: ['email']`): `await email({ to, subject, text?, html?, replyTo?, fromName? })` → `{ sent, skipped, results }`. Platform-brokered and owner-billed (one credit per recipient sent); the From **address** is fixed to `gipity@gipity.ai` (the app sets only `fromName` + `replyTo`). This replaces hand-built outbox tables + cron workflows - just call it where the send decision is made. Verify after deploy with `gipity email test <to>`. Full guide: the `email` skill.\n- **`realtime.publish()`** pushes a message from a function into one of the app's Gipity Realtime rooms (declare `services: ['realtime']`): `await realtime.publish({ room, channel, type, data?, scope?, toUser? })` → `{ delivered, rooms }`. Use it when server work (a webhook, a job's `on_complete`) changes something a page is showing, so the page listens instead of polling a function. Free; `{ delivered: 0 }` when nobody is connected; suppressed in `gipity test`. Recipe: \"Messages from your functions\" in [app-realtime](app-realtime.md).\n- **`embed()`**: text → 1536-number vector for semantic search / RAG over the app DB (pgvector pre-installed). See [app-vector-search](app-vector-search.md).\n- **Declare each one in `services: [...]`** - calling an undeclared service throws. Calls are capped per invocation (`max_llm_calls` for `llm`, `max_service_calls` for the rest).\n- **Calling a billed service from a function requires owner_pays.** Services now default to `user_pays` (safe-to-share - the app's users pay for their own usage). A function has no browser session for user_pays consent, so a `user_pays` service call from it **throws** - switch the service to `owner_pays` (in a `services` deploy phase, see [deploy](deploy.md)) to call it from a function, or make the call from the browser with a signed-in user. **Exceptions: `location`, `notify`, `email`, and `realtime`** are exempt from this gate - `location` and `realtime` are never credit-billed, and `notify`/`email` are always owner-billed (there's no \"user pays to be notified/emailed\" model), so all four work from a function regardless of billing mode.\n\n## Secrets (via `secrets`)\n\n**Never hardcode an API key, password, or token in source.** Store it as a secret and read it at runtime - encrypted at rest (AES-256-GCM), write-only (values never leave the server), and never shipped to the browser.\n\n```js\nconst apiKey = await secrets.get('OPENAI_API_KEY'); // null if unset\n```\n\nSet one from the CLI (UPPER_SNAKE_CASE names):\n\n```bash\ngipity secrets set OPENAI_API_KEY sk-...            # this project only\ngipity secrets set OPENAI_API_KEY sk-... --account  # shared across ALL your projects\ngipity secrets list          # names + masked previews (never values)\n```\n\n**Scope:** a secret is either **project**-scoped (this app) or **account**-scoped (shared across all your projects - set a key like `OPENAI_API_KEY` once and every app inherits it). `secrets.get(name)` resolves the **project** secret first, then falls back to the **account** secret of the same name, so a project can override the shared default.\n\n## Environment Variables (via `env`)\n\n```js\nconst mode = env.APP_MODE; // Read-only\n```\n\n## Logging (via `console`)\n\n```js\nconsole.log('Processing', { orderId });\nconsole.warn('Rate limit approaching');\nconsole.error('Failed', { error: err.message });\n```\n\nCaptured in execution logs (`gipity fn logs <name>`).\n\n## Related Skills\n\n- [app-development](app-development.md) - the core guide: function shape, `ctx`, `gipity.yaml` permissions, deploy and test\n- [app-development-reference](app-development-reference.md) - module rules, `_lib`, verifying webhook signatures, rate limits\n- [deploy](deploy.md) - the `services` deploy phase (billing mode) and every `gipity.yaml` function field\n- Per-service params: `app-llm`, `app-image`, `app-video`, `app-audio`, `app-tts`, `app-location`, `app-notify`, `email`, `app-realtime`, `app-vector-search`\n"
}
