Loop API reference
One endpoint — remote MCP at https://stayinloop.dev/mcp or REST at api.stayinloop.dev/v1/* — where agents search, verify, and act on real local businesses. Four tools. Structured errors. Every field stamped with when it was last seen.
Platform guides
60-second setup guides for the most common MCP clients. Each covers both the settings UI and the config-file approach.
Quick start
A complete agent workflow — from first search to closing the loop — in four calls. No key required during the open wave; swap in sk_live_… when you have one.
Search for results
Natural-language query in, ranked structured results out.
curl https://api.stayinloop.dev/v1/search \ -H "Authorization: Bearer sk_live_…" \ -G \ --data-urlencode 'q=quiet vegan table for 4 tonight' \ --data-urlencode 'location=Kreuzberg, Berlin'
{ "results": [ { "result_id": "7f7250b4-3c1a-4e82-b9d1-0a2f5c8e1234", "name": "Al Catzone - Pizza Napovegana", "vertical": "restaurants", "tags": ["outdoor seating", "vegan only"], "address": "Brandesstraße 7, Kreuzberg, Berlin", "availability": { "status": "likely_open_now", "inferred": true, "basis": "opening hours last observed 2026-06-09", "confidence": 0.75 }, "confidence": 0.95, "cross_source_confirmed": true, "observed_at": "2026-06-10T07:12:09Z", "relevance": 12.4, "restaurant": { "cuisine": ["pizza"], "price_band": { "value": "€", "inferred": true }, "vegan": true, "vegetarian": false, "outdoor_seating": true } } ], "coverage": "Restaurants & salons · Kreuzberg, Berlin", "note": "availability is inferred — call verify(result_id) before acting on it." }
Get the full record
Use result_id from the search result. This returns the complete record plus a result_token(valid 30 min) you’ll need for report().
curl https://api.stayinloop.dev/v1/details/7f7250b4-3c1a-4e82-b9d1-0a2f5c8e1234 \ -H "Authorization: Bearer sk_live_…"
{ "result_id": "7f7250b4-3c1a-4e82-b9d1-0a2f5c8e1234", "name": "Al Catzone - Pizza Napovegana", "vertical": "restaurants", "area": "Kreuzberg", "address": "Brandesstraße 7, Kreuzberg, Berlin", "location": { "lat": 52.4891, "lng": 13.4142 }, "attrs": { "opening_hours": "Mo-Sa 12:00-22:00", "phone": "+49 30 123456", "website": "https://alcatzone.de" }, "tags": ["outdoor seating", "vegan only"], "availability": { "status": "likely_open_now", "inferred": true, "confidence": 0.75 }, "confidence": 0.95, "cross_source_confirmed": true, "observed_at": "2026-06-10T07:12:09Z", "source": "OpenStreetMap + Foursquare OS Places (independently confirmed) + agent reports", "result_token": "eyJ…", "note": "When the outcome is known, call report(result_token, outcome).", "restaurant": { "cuisine": ["pizza"], "price_band": { "value": "€", "inferred": true }, "vegan": true, "vegetarian": false, "outdoor_seating": true } }
Verify before acting
Optional but recommended. If the record is stale, Loop re-checks it live against OpenStreetMap right now and returns fresh data with a new observed_at.
curl https://api.stayinloop.dev/v1/verify/7f7250b4-3c1a-4e82-b9d1-0a2f5c8e1234 \ -H "Authorization: Bearer sk_live_…" \ -G --data-urlencode 'claim=Is it open on Sunday evenings?'
{ "result_id": "7f7250b4-3c1a-4e82-b9d1-0a2f5c8e1234", "name": "Al Catzone - Pizza Napovegana", "claim": "Is it open on Sunday evenings?", "latest_observation": { "type": "recheck", "value": "Mo-Sa 12:00-22:00", "source": "osm_recheck", "confidence": 0.85, "observed_at": "2026-06-11T14:03:22Z" }, "observed_at": "2026-06-11T14:03:22Z", "record_confidence": 0.95, "availability": { "status": "likely_closed_now", "inferred": true, "confidence": 0.85 }, "note": "Re-verified live against OpenStreetMap just now." }
Report the outcome
Tell Loop what happened. This immediately mutates the record’s confidence and observed_at. Agents that report earn fresher data and higher rate limits.
curl -X POST https://api.stayinloop.dev/v1/report \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"result_token": "eyJ…", "outcome": "correct"}'
{ "ok": true, "merchant_id": "7f7250b4-3c1a-4e82-b9d1-0a2f5c8e1234", "new_confidence": 0.97, "observed_at": "2026-06-11T14:07:55Z", "note": "Outcome recorded — the record's freshness and confidence were just updated." }
Connect
Loop exposes the same data and tools over remote MCP (streamable HTTP) and REST. Pick whichever fits your stack.
MCP server
Paste https://stayinloop.dev/mcp into Claude (Settings → Connectors), Cursor, or any MCP-compatible client. Or add it to a config file:
{ "mcpServers": { "loop": { "url": "https://stayinloop.dev/mcp" } } }
{ "mcpServers": { "loop": { "url": "https://stayinloop.dev/mcp?key=sk_live_…" } } }
REST API
Base URL: https://api.stayinloop.dev/v1. All endpoints are no-store — responses are never cached. Pass your key via the Authorization header or ?key= query parameter.
ChatGPT
ChatGPT Business and Enterprise/Edu accounts accept MCP connectors in developer mode. Paste https://stayinloop.dev/mcp, leave authentication empty, and you’re done. Plus / Pro users: use the OpenAPI spec for a custom GPT via Actions at api.stayinloop.dev/v1/openapi.json.
Vercel AI SDK
import { experimental_createMCPClient } from 'ai'; import { streamText } from 'ai'; import { anthropic } from '@ai-sdk/anthropic'; const loop = await experimental_createMCPClient({ transport: { type: 'sse', url: 'https://stayinloop.dev/mcp?key=sk_live_…' }, }); const result = await streamText({ model: anthropic('claude-sonnet-4-6'), tools: await loop.tools(), messages: [{ role: 'user', content: 'Find a vegan restaurant open now in Kreuzberg' }], });
OpenAI function calling
import OpenAI from 'openai'; const client = new OpenAI(); // fetch tool definitions from the OpenAPI spec const spec = await fetch('https://api.stayinloop.dev/v1/openapi.json').then(r => r.json()); const response = await client.chat.completions.create({ model: 'gpt-4o', tools: spec.tools, // drop Loop's OpenAPI tools straight in messages: [{ role: 'user', content: 'Vegan dinner Kreuzberg tonight' }], });
Authentication
No key is required during the open wave — you get 30 read calls/min and 10 report calls/min, attributed to an anonymous agent. With an API key you get named agent attribution, higher limits, and priority freshness windows.
| Parameter | Type | Description | |
|---|---|---|---|
| Authorization | header | optional | Bearer sk_live_… — pass your API key here on REST calls. |
| ?key= | query param | optional | Append key=sk_live_… to any REST or MCP URL as an alternative to the header. |
Keys follow the format sk_live_…. To request one, use the form on the homepage or email hello@stayinloop.dev. You can also .
search()
Natural-language search over the live merchant catalog. Filters can be passed explicitly or are inferred automatically from the query text (e.g. “vegan options” sets vegan=true).
| Parameter | Type | Description | |
|---|---|---|---|
| q | string | required | Natural-language query. Max 200 characters. Aliases: q or query. |
| location | string | optional | Location context. Example: "Kreuzberg, Berlin". Max 120 characters. Defaults to full coverage area. |
| vegan | boolean | optional | Filter to vegan-friendly venues only. |
| vegetarian | boolean | optional | Filter to vegetarian-friendly venues only. |
| outdoor | boolean | optional | Filter to venues with outdoor seating. |
| open_now | boolean | optional | Filter to venues inferred to be open right now based on observed hours. |
| price_band | string | optional | Filter by price band. One of: "€", "€€", "€€€". |
Response: { results[], coverage, note }
Returns up to 8 ranked results. Out-of-coverage queries return { results: [], coverage: "…" } — never junk results.
Each result includes result_id (UUID — pass to get_details or verify), confidence (0–1), observed_at (ISO 8601), and availability.status — always inferred: true until a merchant connects directly.
get_details()
Returns the full structured record for a chosen result — all available fields, plus a result_token required to call report(). Calling this records a selection signal.
| Parameter | Type | Description | |
|---|---|---|---|
| result_id | UUID | required | The result_id from a search() response. Must be a valid UUID. |
Response: full merchant record including attrs (universal fields: opening_hours, phone, website), a [vertical] sub-object (e.g. restaurant: { cuisine, vegan, … }), location (lat/lng if known), source (data provenance), and result_token.
get_details again to get a fresh one. Pass it to report() to close the loop.verify()
Freshness check before acting. Returns the most recent observation for the record. If the record is stale, Loop re-checks it live against OpenStreetMap right now (demand-driven live re-verification) and returns the fresh result.
| Parameter | Type | Description | |
|---|---|---|---|
| result_id | UUID | required | The result_id to verify. |
| claim | string | optional | An optional natural-language claim to check. Example: "Is it open on Sunday evenings?" Max 200 characters. Echoed back in the response. |
Response includes latest_observation (the most recent data point with its source and confidence), record_confidence (current overall confidence of the full record), and availability.
The note field tells you whether data was served from cache or re-verified live: “Re-verified live against OpenStreetMap just now” vs “Last observed from seed data plus agent reports”.
report()
Close the loop. Tell Loop what actually happened — the record’s confidence and observed_at are updated immediately. Agents that report earn fresher data and higher rate limits.
| Parameter | Type | Description | |
|---|---|---|---|
| result_token | string | required | The result_token from get_details(). Valid for 30 minutes. |
| outcome | enum | required | One of: correct · wrong · booked · closed · other. correct = info was accurate. wrong = something was incorrect. booked = reservation/order succeeded. closed = place was closed or gone. other = catch-all. |
curl -X POST https://api.stayinloop.dev/v1/report \ -H "Authorization: Bearer sk_live_…" \ -H "Content-Type: application/json" \ -d '{"result_token": "eyJ…", "outcome": "correct"}'
Response: { ok: true, merchant_id, new_confidence, observed_at, note }
Data model
Every merchant record follows the same normalized schema across all verticals. Fields present depend on available data — missing fields are omitted, never returned as null.
Search result fields
| Parameter | Type | Description | |
|---|---|---|---|
| result_id | UUID | required | Stable identifier for this merchant across all calls. |
| name | string | required | Merchant name. |
| vertical | string | required | Merchant category. Determines which vertical sub-object is present. E.g. "restaurants" or "salons". |
| tags | string[] | optional | Derived feature tags. E.g. ["outdoor seating", "vegan only"]. |
| address | string | optional | Human-readable address. Formatted as street + housenumber, area, city. |
| availability | object | required | See Availability object below. |
| confidence | number | required | 0–1 overall record confidence. See Confidence & freshness. |
| cross_source_confirmed | boolean | required | True if confirmed by both OpenStreetMap and Foursquare OS Places. |
| observed_at | ISO 8601 | required | Timestamp of the last observation. |
| relevance | number | required | Search relevance score for this query (not comparable across queries). |
| [vertical] | object | optional | Vertical-specific attributes keyed by vertical name. Present when vertical-specific data is available. See vertical schemas below. |
Availability object
| Parameter | Type | Description | |
|---|---|---|---|
| status | enum | required | likely_open_now · likely_closed_now · unknown |
| inferred | true | required | Always true until a merchant connects directly. Never fabricated. |
| basis | string | optional | Human-readable basis for the inference. E.g. "opening hours last observed 2026-06-09". |
| confidence | number | required | 0–1 confidence in the availability inference. |
Extended fields (get_details only)
| Parameter | Type | Description | |
|---|---|---|---|
| area | string | required | Neighborhood. E.g. "Kreuzberg". |
| location | object | optional | { lat: number, lng: number } — present when coordinates are known. |
| attrs.opening_hours | string | optional | Universal. Raw OSM opening_hours string. E.g. "Mo-Sa 12:00-22:00". |
| attrs.phone | string | optional | Universal. Phone number. |
| attrs.website | string | optional | Universal. Website URL. |
| source | string | required | Data provenance description. |
| result_token | string | required | Opaque token for report(). Valid 30 minutes. |
restaurant — vertical schema
Present on all results where vertical === "restaurants". Agents should check vertical before reading this sub-object. Salons (vertical === "salons") follow the same pattern with their own named sub-object; further verticals (fitness, hotels…) will too.
| Parameter | Type | Description | |
|---|---|---|---|
| cuisine | string[] | optional | Cuisine types. E.g. ["pizza", "italian"]. |
| price_band | object | optional | { value: "€"|"€€"|"€€€", inferred: boolean }. Inferred when not directly observed. |
| vegan | boolean | optional | True if venue serves vegan options. |
| vegetarian | boolean | optional | True if venue serves vegetarian options. |
| outdoor_seating | boolean | optional | True if outdoor seating is available. |
Confidence & freshness
Loop is honest about what it doesn’t know. Every field that is inferred says so. No silent guesses.
confidence
A 0–1 score derived from: number of independent sources that confirm the record, recency of the last observation, and the volume and direction of report() outcomes. Higher is more trustworthy.
As a rule of thumb: ≥ 0.9 = cross-confirmed, recently observed. 0.7–0.9 = reasonable, verify before acting. < 0.7 = stale or single-sourced — call verify().
availability.inferred
Always true until a merchant connects directly to Loop. Availability is inferred from the last observed opening hours, not confirmed in real time. verify() triggers a live OSM re-check if the record is stale and may return a more current availability status.
observed_at
ISO 8601 timestamp of the last time this record or field was observed from a source. Not the time of your request. Use this to decide whether to call verify(): if observed_at is more than a few hours old and the action matters (booking, routing, telling a user), verify first.
Rate limits
Limits are per-IP per minute. The Retry-After: 60 header is always present on 429 responses.
| Tier | Read calls / min | Report calls / min | Attribution |
|---|---|---|---|
| Anonymous (no key) | 30 | 10 | anonymous agent |
| Free (with key) | 60 | 20 | named agent label |
| Pro | custom | custom | named + dashboard |
Agents that consistently call report() with accurate outcomes earn higher limits as a side effect of improving the data quality.
Errors
All errors return structured JSON — never raw 500s for known bad inputs. Every error carries an error code and a suggested_action hint agents can branch on to self-recover without human intervention.
| error code | HTTP | suggested_action | Meaning |
|---|---|---|---|
| missing_query | 400 | add_q_parameter | No q parameter on a search call. |
| unknown_result_id | 404 | search_again | result_id is not found or is not a valid UUID. |
| invalid_result_token | 400 | call_get_details | Token is malformed or was not issued by Loop. |
| expired_result_token | 400 | call_get_details | Token is older than 30 minutes — call get_details again. |
| invalid_outcome | 400 | use_valid_outcome | outcome is not one of: correct, wrong, booked, closed, other. Response includes allowed[]. |
| invalid_json | 400 | send_valid_json | POST body is not valid JSON. |
| rate_limited | 429 | retry_after | Over the per-minute limit. Response includes retry_after_seconds. Check Retry-After header. |
| report_failed | 500 | retry | Internal error recording the report — safe to retry. |
HTTP/1.1 429 Too Many Requests Retry-After: 60 Content-Type: application/json { "error": "rate_limited", "suggested_action": "retry_after", "retry_after_seconds": 60, "detail": "32 calls this minute (limit 30)" }
{ "error": "expired_result_token", "suggested_action": "call_get_details" }
{ "error": "invalid_outcome", "suggested_action": "use_valid_outcome", "allowed": ["correct", "wrong", "booked", "closed", "other"] }
Coverage
Loop currently covers restaurants and salons in Kreuzberg, Berlin: 582 merchants seeded from OpenStreetMap. The 424 restaurants are cross-confirmed with Foursquare OS Places (312 confirmed); the 158 hair & beauty salons are OpenStreetMap single-source. Coverage expands by vertical as agent demand signals show where to go next.
Out-of-coverage behavior
Queries outside the current coverage area return a structured response, never junk:
{ "results": [], "coverage": "Loop currently covers restaurants and salons in Kreuzberg, Berlin. More cities and verticals are coming — agent demand signals guide where we expand next.", "note": "Try a query in Kreuzberg, Berlin." }
Request expansion
To request a new city or vertical — or to ask about a nonprofit/research access tier — email hello@stayinloop.dev. Include your use case and rough call volume. Expansion is prioritized by agent demand signals already in the system.
Data sources
Merchant records are seeded from OpenStreetMap (ODbL) and cross-confirmed with Foursquare OS Places (Apache 2.0). Live re-verification against OSM happens on demand when verify() is called on a stale record. Agent report() calls immediately update confidence and freshness.