A 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 first; the db helper is in app-database and the storage helper in app-files-storage.
External HTTP (via fetch)
export default async function getWeather(ctx, { fetch }) {
const { zip } = ctx.body;
const res = await fetch('https://api.example.com/weather?zip=' + zip);
return await res.json();
}
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).
Security: Only declared fetch_domains are allowed. Private IPs blocked (SSRF protection).
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}`)`.
fetchis for third-party APIs only. Don'tfetchthe platform's own services ata.gipity.ai(llm/image/tts/location/...) - the sandbox blocks every*.gipity.aifetch, andfetch_domains: ['a.gipity.ai']does not unblock it. Call those through the injected app-service capabilities below instead.
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:
- name: get-weather
auth: public
fetch_domains: [api.example.com]
Verifying webhook signatures (via crypto)
A 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 services from a function (via llm, image, tts, ...)
The 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:
export default async function summarize(ctx, { db, llm }) {
const { rows } = await db.query('SELECT body FROM notes WHERE user_guid = $1', [ctx.auth.userGuid]);
const { text } = await llm({ model: 'fast', messages: [
{ role: 'user', content: 'Summarize:\n' + rows.map(r => r.body).join('\n') },
]});
await db.query('INSERT INTO summaries (user_guid, summary) VALUES ($1, $2)', [ctx.auth.userGuid, text]);
return { summary: text };
}
- name: summarize
auth: user
tables: ['notes', 'summaries']
services: ['llm'] # required - declares which services this function may call
- Available:
llm·image(plusimage.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, ... }, ...). Seeapp-llm,app-image,app-tts,app-audio,app-video,app-location,app-notify,email,app-vector-searchfor per-service params. llm()JSON answers: passresponse_format: { type: 'json_schema', json_schema: { name, schema } }(models that support it, e.g. OpenAI's andgipity-qwen3.6-35b) or{ type: 'json_object' }(any model), thenJSON.parse(text). The half-price Gipity GPU model can throwMODEL_WARMING(retry after about 45 s) orPROVIDER_BUSY; see app-llm.email()sends from a deployed app (declareservices: ['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 togipity@gipity.ai(the app sets onlyfromName+replyTo). This replaces hand-built outbox tables + cron workflows - just call it where the send decision is made. Verify after deploy withgipity email test <to>. Full guide: theemailskill.realtime.publish()pushes a message from a function into one of the app's Gipity Realtime rooms (declareservices: ['realtime']):await realtime.publish({ room, channel, type, data?, scope?, toUser? })→{ delivered, rooms }. Use it when server work (a webhook, a job'son_complete) changes something a page is showing, so the page listens instead of polling a function. Free;{ delivered: 0 }when nobody is connected; suppressed ingipity test. Recipe: "Messages from your functions" in app-realtime.embed(): text → 1536-number vector for semantic search / RAG over the app DB (pgvector pre-installed). See app-vector-search.- Declare each one in
services: [...]- calling an undeclared service throws. Calls are capped per invocation (max_llm_callsforllm,max_service_callsfor the rest). - 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 auser_paysservice call from it throws - switch the service toowner_pays(in aservicesdeploy phase, see deploy) to call it from a function, or make the call from the browser with a signed-in user. Exceptions:location,notify,email, andrealtimeare exempt from this gate -locationandrealtimeare never credit-billed, andnotify/emailare always owner-billed (there's no "user pays to be notified/emailed" model), so all four work from a function regardless of billing mode.
Secrets (via secrets)
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.
const apiKey = await secrets.get('OPENAI_API_KEY'); // null if unset
Set one from the CLI (UPPER_SNAKE_CASE names):
gipity secrets set OPENAI_API_KEY sk-... # this project only
gipity secrets set OPENAI_API_KEY sk-... --account # shared across ALL your projects
gipity secrets list # names + masked previews (never values)
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.
Environment Variables (via env)
const mode = env.APP_MODE; // Read-only
Logging (via console)
console.log('Processing', { orderId });
console.warn('Rate limit approaching');
console.error('Failed', { error: err.message });
Captured in execution logs (gipity fn logs <name>).
Related Skills
- app-development - the core guide: function shape,
ctx,gipity.yamlpermissions, deploy and test - app-development-reference - module rules,
_lib, verifying webhook signatures, rate limits - deploy - the
servicesdeploy phase (billing mode) and everygipity.yamlfunction field - Per-service params:
app-llm,app-image,app-video,app-audio,app-tts,app-location,app-notify,email,app-realtime,app-vector-search