# Build an app with Sightline An app is an OAuth public client, as an agent is. The two clients call the same 32 tools through `POST /mcp` with JSON-RPC. Sightline has no separate app API or SDK package. The user approves a grant on the control page. The grant limits access to the selected household and scope. ## Build your own app Start at the public [Connect page](https://www.sightlineapp.com/agents). The page gives the MCP URL and the access presets. Use the API origin of that URL in place of `https://my.sightlineapp.com` below. ## Register a public client Read `/.well-known/oauth-protected-resource/mcp` from the API origin. Read `/.well-known/oauth-authorization-server` from its authorization server. Use the endpoint URLs from these documents. Send this JSON body to `POST /oauth/register`: ```json { "client_name": "Example app", "redirect_uris": ["https://app.example/callback"], "token_endpoint_auth_method": "none", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"] } ``` Keep the returned `client_id` and redirect address. A public client has no client secret. A client can also use an HTTPS Client ID Metadata Document. The document must give its own URL as `client_id` and the same redirect metadata. The two methods use these redirect rules: - HTTPS needs a host. - HTTP permits only `127.0.0.1`, `[::1]`, or `localhost`, with an optional port. - A private-use scheme needs reverse domain form, such as `com.example.app:/oauth/callback`. - A redirect address cannot contain a fragment, user name, or password. - Other schemes, such as `javascript:`, `data:`, `file:`, and `app:`, are invalid. Register the exact callback address that your app will use. The authorize request cannot change the path, query, or loopback port. Use the returned address if the URL parser changes its format. ## Request limits Each registration and client document permits at most 5 redirect addresses. Each address permits at most 2000 characters. The client name permits at most 100 characters. The default body limit is 64 KiB. The tool endpoint permits 1 MiB, which accepts 200 transaction rows. A larger body gives HTTP 413 with error code `body_too_large`. The body read has a 10-second deadline. Each client address has a separate rate limit for each public endpoint. The defaults per 60 seconds are: registration 10, authorization 30, token exchange 60, and revocation 60. The tool endpoint permits 120 requests per grant per 60 seconds. The control API permits 120 requests per user per 60 seconds. HTTP 429 gives error code `rate_limited`. The `Retry-After` header gives the wait in seconds. The operator can change these defaults. `get_net_worth` permits at most 366 inclusive days per history call, for all sample intervals. The service reads daily rows even when the output uses weekly or monthly samples. Use separate calls for a longer range. A larger range gives `invalid_input` with the range limit in its hint. Each tool request has a 20-second deadline after the body read. A deadline failure gives `request_timeout`. A request deadline gives HTTP 504. A database statement deadline can give an MCP tool error with the same code. The recalculation limit stays at 10 seconds. When the database pool stays full for 1 second, the request gives HTTP 503 with `Retry-After: 1`. ## Start authorization with PKCE 1. Create a random PKCE verifier with 43 to 128 characters from `A-Z`, `a-z`, `0-9`, `-`, `.`, `_`, and `~`. 2. Calculate its SHA-256 digest. 3. Encode the digest with base64url, without `=` characters, to make the challenge. 4. Create a separate random `state` value. 5. Keep the verifier and state until the callback. 6. Open `/oauth/authorize` in the browser with these query parameters: | Parameter | Value | | --- | --- | | `response_type` | `code` | | `client_id` | The registered client ID | | `redirect_uri` | The exact registered redirect address | | `code_challenge` | The base64url challenge | | `code_challenge_method` | `S256` | | `resource` | The full MCP URL, such as `https://my.sightlineapp.com/mcp` | | `state` | The random state value | The user signs in through Clerk and selects a household. The control page starts with Read only when that preset fits the member scope. A restricted member gets a permitted preset with read access. The user can select more access within the member scope. An app gets the same access as an agent. The Agents and apps list can revoke the grant. ## Exchange the code 1. Check that the callback address matches your callback route. 2. Compare the callback `state` with the saved state. 3. Compare the callback `iss` with the authorization server issuer. 4. If the callback contains `error`, stop the connection. 5. Send a form body to `POST /oauth/token`: ```text grant_type=authorization_code client_id= code= code_verifier= redirect_uri= resource=https://my.sightlineapp.com/mcp ``` Use `application/x-www-form-urlencoded` and URL-encode each value. The code expires after 60 seconds and works one time. The JSON response contains `access_token`, `refresh_token`, `token_type`, and `expires_in`. An access token lasts one hour. ## Call a tool 1. Send an `initialize` request to `POST /mcp`. 2. Send the `notifications/initialized` notification. 3. Send `tools/list` to read the tool schemas. 4. Send a `tools/call` request with the tool name and arguments. Use these headers for MCP requests: ```text Authorization: Bearer Content-Type: application/json Accept: application/json, text/event-stream MCP-Protocol-Version: 2025-11-25 ``` The service accepts this protocol version and returns JSON without server sessions. The examples use this version. The `initialize` request includes the same `protocolVersion`, empty `capabilities`, and a `clientInfo` name and version. A notification has no JSON-RPC `id` and returns status `202`. A tool call has this body: ```json { "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "list_accounts", "arguments": {"limit": 100} } } ``` The result has `structuredContent` and the same JSON as text in `content`. Check for a JSON-RPC `error` before you read `result`. Check `result.isError` before you use `result.structuredContent`. A tool error can occur with HTTP status `200`. Amounts, quantities, shares, and rates are decimal strings. Keep their exact values in your app. ## Writes Use a stable `external_id` when the tool accepts that field. Repeat the same ID and content to get the first record. An external ID can contain a UUID or a valid ISO calendar date (`YYYY-MM-DD`). It must not contain an account number, an e-mail address, or a telephone number. ## Read pages with a cursor 1. Call `list_accounts` with `limit: 100` and no cursor. 2. Read `result.structuredContent.accounts`. 3. If `next_cursor` is not null, call the same tool with that value as `arguments.cursor`. 4. Keep the same filters and limit for each page. 5. Continue until `next_cursor` is null. A page limit cannot exceed 500. The same cursor method applies to other tools with `limit` and `cursor` inputs. The cursor is opaque. Do not decode or change it. ## Refresh and revoke Send this form body to `POST /oauth/token` before the access token expires: ```text grant_type=refresh_token client_id= refresh_token= resource=https://my.sightlineapp.com/mcp ``` Use each refresh token one time. Replace both saved tokens with the returned tokens before the next request. Serialize refresh requests so that two requests cannot use the same refresh token. A refresh token expires after 90 days without use. If the response is lost, start authorization again. Do not reuse the old refresh token. Send this form body to `POST /oauth/revoke` to remove token access: ```text client_id= token= token_type_hint=refresh_token ``` Revocation invalidates the tokens of that grant. The user can also revoke the grant in the Agents and apps list on the control page. ## Handle status 401 Read the `WWW-Authenticate` header for the protected resource metadata URL. If the access token expired, use the current refresh token once. Retry the failed request once with the new access token. If refresh fails with `invalid_grant`, discard the tokens and start authorization again. If the retry returns `401`, start authorization again. A removed member or a revoked grant also causes `401`. Do not repeat a failed write without its stable `external_id` when the tool accepts that field. ## Browser example: TypeScript This browser module uses only browser APIs. It registers the current page as its callback. Serve the page through HTTPS or a permitted loopback HTTP address. The example keeps tokens in memory and the PKCE values in session storage across navigation. A page reload after the exchange needs a new connection. ```typescript export {}; const api = 'https://my.sightlineapp.com', redirect = location.origin + location.pathname; const key = 'sightline-pkce'; const b64 = (b: Uint8Array) => btoa(String.fromCharCode(...b)) .replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', ''); const random = () => b64(crypto.getRandomValues(new Uint8Array(32))); async function request(path: string, init: RequestInit) { const r = await fetch(api + path, { ...init, credentials: 'omit' }); if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.status === 202 ? null : r.json(); } const callback = new URLSearchParams(location.search); if (!callback.has('code') && !callback.has('error')) { const client = await request('/oauth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_name: 'Browser app', redirect_uris: [redirect], token_endpoint_auth_method: 'none' }), }); const verifier = random(), state = random(); const challenge = b64(new Uint8Array(await crypto.subtle.digest( 'SHA-256', new TextEncoder().encode(verifier)))); sessionStorage.setItem(key, JSON.stringify({ verifier, state, id: client.client_id })); location.assign(api + '/oauth/authorize?' + new URLSearchParams({ response_type: 'code', client_id: client.client_id, redirect_uri: redirect, code_challenge: challenge, code_challenge_method: 'S256', resource: api + '/mcp', state, })); } else { history.replaceState(null, '', redirect); const saved = JSON.parse(sessionStorage.getItem(key) ?? 'null'); sessionStorage.removeItem(key); if (!saved || callback.get('state') !== saved.state || callback.get('iss') !== api) throw new Error('Invalid callback'); if (callback.has('error')) throw new Error('Authorization failed'); const tokens = await request('/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', client_id: saved.id, code: callback.get('code')!, code_verifier: saved.verifier, redirect_uri: redirect, resource: api + '/mcp' }), }); let id = 0; const rpc = (method: string, params: object, notification = false) => request('/mcp', { method: 'POST', headers: { Authorization: `Bearer ${tokens.access_token}`, 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', 'MCP-Protocol-Version': '2025-11-25' }, body: JSON.stringify({ jsonrpc: '2.0', ...(notification ? {} : { id: ++id }), method, params }), }); await rpc('initialize', { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: { name: 'Browser app', version: '1' } }); await rpc('notifications/initialized', {}, true); let cursor: string | null = null; do { const r = await rpc('tools/call', { name: 'list_accounts', arguments: { limit: 100, ...(cursor ? { cursor } : {}) } }); if (r.error || r.result.isError) throw new Error('Tool request failed'); const page = r.result.structuredContent; document.dispatchEvent(new CustomEvent('sightline-accounts', { detail: page.accounts })); cursor = page.next_cursor; } while (cursor); } ``` ## Native example: Swift This example uses Apple system frameworks, with no external package. Register the `com.example.app` URL scheme in the app target. Call `connect` from the app with its presentation context provider. The system browser handles authorization. The function stores tokens in the Keychain and calls `get_household`. This stateless endpoint also accepts a direct tool call without an MCP handshake. The native example uses that method with protocol version `2025-11-25`. ```swift import Foundation import CryptoKit import Security import AuthenticationServices @MainActor func connect(_ context: ASWebAuthenticationPresentationContextProviding) async throws -> [String: Any] { let api = "https://my.sightlineapp.com", redirect = "com.example.app:/oauth/callback" func b64(_ data: Data) -> String { data.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "") } func random() throws -> String { var bytes = [UInt8](repeating: 0, count: 32) guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { throw URLError(.unknown) } return b64(Data(bytes)) } func post(_ path: String, _ data: Data, _ type: String, _ token: String? = nil) async throws -> Data { var r = URLRequest(url: URL(string: api + path)!) r.httpMethod = "POST"; r.httpBody = data r.setValue(type, forHTTPHeaderField: "Content-Type") r.setValue("application/json, text/event-stream", forHTTPHeaderField: "Accept") r.setValue("2025-11-25", forHTTPHeaderField: "MCP-Protocol-Version") if let token { r.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } let (body, response) = try await URLSession.shared.data(for: r) guard (200..<300).contains((response as! HTTPURLResponse).statusCode) else { throw URLError(.badServerResponse) } return body } let registration = try JSONSerialization.data(withJSONObject: ["redirect_uris": [redirect], "token_endpoint_auth_method": "none"]) let client = try JSONSerialization.jsonObject(with: await post("/oauth/register", registration, "application/json")) as! [String: Any] let verifier = try random(), state = try random(), id = client["client_id"] as! String let challenge = b64(Data(SHA256.hash(data: Data(verifier.utf8)))) var url = URLComponents(string: api + "/oauth/authorize")! url.queryItems = ["response_type": "code", "client_id": id, "redirect_uri": redirect, "resource": api + "/mcp", "state": state, "code_challenge": challenge, "code_challenge_method": "S256"].map { URLQueryItem(name: $0.key, value: $0.value) } var browser: ASWebAuthenticationSession? let callback: URL = try await withCheckedThrowingContinuation { continuation in browser = ASWebAuthenticationSession(url: url.url!, callbackURLScheme: "com.example.app") { u, e in if let u { continuation.resume(returning: u) } else { continuation.resume(throwing: e ?? URLError(.cancelled)) } } browser!.presentationContextProvider = context if !browser!.start() { continuation.resume(throwing: URLError(.unknown)) } } withExtendedLifetime(browser) {} let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)!.queryItems ?? [] let values = items.reduce(into: [String: String]()) { $0[$1.name] = $1.value } guard callback.scheme == "com.example.app", callback.path == "/oauth/callback", values["state"] == state, values["iss"] == api, values["error"] == nil, let code = values["code"] else { throw URLError(.badServerResponse) } let fields = ["grant_type": "authorization_code", "client_id": id, "code": code, "code_verifier": verifier, "redirect_uri": redirect, "resource": api + "/mcp"] let form = fields.map { "\($0.key)=\($0.value.addingPercentEncoding(withAllowedCharacters: .alphanumerics)!)" }.joined(separator: "&") let body = try await post("/oauth/token", Data(form.utf8), "application/x-www-form-urlencoded") let query = [kSecClass: kSecClassGenericPassword, kSecAttrService: "Sightline", kSecAttrAccount: id] as CFDictionary let update = SecItemUpdate(query, [kSecValueData: body] as CFDictionary) let add = [kSecClass: kSecClassGenericPassword, kSecAttrService: "Sightline", kSecAttrAccount: id, kSecValueData: body, kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly] as CFDictionary guard update == errSecSuccess || (update == errSecItemNotFound && SecItemAdd(add, nil) == errSecSuccess) else { throw URLError(.cannotWriteToFile) } let tokens = try JSONSerialization.jsonObject(with: body) as! [String: Any] let rpc = try JSONSerialization.data(withJSONObject: ["jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": ["name": "get_household", "arguments": [:]]]) let result = try await post("/mcp", rpc, "application/json", tokens["access_token"] as? String) return try JSONSerialization.jsonObject(with: result) as! [String: Any] } ``` ## What the first three apps found Three clients use the service as an app of a user does: a native iOS app, a web app, and a command-line tool. Each one signs in with Sightline and reads and writes only through the tools. Their live tests found these points. | Subject | What a client must know | | --- | --- | | Result shape | A result can contain null inside a free object. `get_net_worth` returns its report controls in `filters`, with null for each control that the request did not set. Do not reject a result because of them. | | Daily comparison | Read the report of the day and the report of the day before in two calls. The `as_of` control of each result is in `filters`. | | Index prices | A market index is an instrument of the kind `index`. Find it one time through `search_instruments` (for example `^GSPC`). Then read its closes through `get_instrument_prices` with `benchmark`. The price task adds the closes of a new instrument on the next market day. | | After a write | The first net worth read after a write can wait for a recalculation. It can take some seconds for a write with an early date. Show the earlier data during that time. | | Batches | `create_transactions` accepts 200 rows in one call. | | External IDs | Use a UUID, with a prefix for your app. An ISO date is permitted also. A different sequence of six or more digits gives `text_rejected`. | | Refresh in a browser | A browser can send a request a second time after a connection loss. A second use of a refresh token revokes the grant. Remove the token from storage before the request, and use one refresh for all tabs. A request with a stream body is not sent a second time by the browser. The web app does this over HTTP/2. | | Refresh in more than one process | A command-line tool can run two times at the same moment. Use a lock file for the refresh. | | Unknown result | A timeout does not tell you if a write occurred. Keep the external ID and the content, and send exactly the same request again. The service then returns the first record. | | Limited grant | Detail `balances` includes holdings, and it excludes transactions, income, and relationships. Read the scope from `get_started_with_sightline`, and show a subtotal label for a limited grant. | | Human actions | Sign-up, more access, members, bank connections, and household deletion are on the control page. Open the link from `get_control_page_link`. | | Two addresses of a client | A web app can use a client document at its own HTTPS address as its client ID. A local development server and a native app use dynamic registration. | ## Security rules - Store tokens in the Keychain on a device. - Never put an access token or refresh token in a URL. - Treat a refresh token as a secret. - Do not log tokens, authorization codes, verifier values, or financial data. - Ask for read-only access unless the app writes data. - Use PKCE with S256 and a new random state for each authorization request. - Check the callback state and issuer before the code exchange. - Use the system browser for native authorization. - Keep browser tokens in memory, as in the example. - Exclude tokens from browser history, analytics, and error reports. - For browser requests, use `credentials: 'omit'`. CORS permits all origins for MCP, token exchange, registration, revocation, and OAuth metadata. These endpoints need no cookies. The control API permits only the site origin and uses Clerk session tokens. An app uses navigation to `/oauth/authorize`, not a script request to that endpoint. ## The 32 public tools - `get_started_with_sightline` (read): Read the setup skill, household state, and scope of the grant first. - `list_sightline_skills` (read): List the available skills, versions, and variants. - `get_sightline_skill` (read): Read a skill for an entity and tax year within the grant scope. - `get_household` (read): Read household currency, counts, and data freshness within scope. - `update_household` (write): Change the household currency with full household scope. - `get_control_page_link` (read): Get the control page URL for human setup and access actions. - `get_net_worth` (read): Read net worth, history, components, and completeness within scope. - `list_tax_entities` (read): List tax entities within the grant scope. - `create_tax_entity` (write): Create a person, corporation, or trust after a check for duplicates. - `update_tax_entity` (write): Change an entity alias, jurisdiction, currency, or flags. - `list_entity_relationships` (read): List dated ownership shares and trust roles. - `set_entity_relationship` (write): Set an ownership share or trust role with inclusive dates. - `list_accounts` (read): List accounts with owners, selected values, and data freshness. - `create_account` (write): Create a manual account with an optional first dated value. - `update_account` (write): Change account details, valuation mode, or dated owners. - `record_account_value` (write): Add a dated value observation in the account currency. - `list_account_values` (read): Read account observations and selected daily values. - `list_bank_connections` (read): List bank connections, account summaries, and last import times. - `list_holdings` (read): List dated holdings, prices, and account total comparisons. - `record_holding` (write): Record an instrument quantity or cash amount at the end of a date. - `search_instruments` (read): Find public instruments and indexes for holdings and benchmarks. - `get_instrument_prices` (read): Read dated public instrument prices and benchmark closes. - `get_exchange_rates` (read): Read dated exchange rates for currency conversion. - `list_transactions` (read): List ledger records with account, entity, date, type, and category filters. - `create_transactions` (write): Create up to 200 manual transaction rows in one atomic write. - `update_transaction` (write): Change a manual transaction or classify a bank transaction. - `summarize_transactions` (read): Sum cash amounts and counts by currency and selected groups. - `list_income_sources` (read): List income sources, expectations, yearly rates, and recipients. - `set_income_source` (write): Create or change an income source and its expected amounts. - `record_income_report` (write): Record gross income and tax withheld for an inclusive period. - `list_income_reports` (read): List income reports by recipient, source, and date range. - `delete_record` (write): Delete a manual record and retain its prior values in the change log.