The run surface
Two tools, two jobs
Oddlynew's MCP server exposes exactly two tools: guide and run. guide is the read-only cold start: it searches and reads maintained public guidance for you. run executes JavaScript server-side against the namespaces your connection exposes for actual organization reads and operations.
run takes one parameter, code: JavaScript statements that run in an async context, so top-level await works directly and whatever you return is the run's result:
const me = await orientation.whoami()
return { userId: me.userId, organizations: me.organizationCount }Rules for what you write:
Plain JavaScript. No TypeScript annotations, interfaces, or generics.
The sandbox has no network access. There is no
fetch, no timers, and no storage;cryptooffers onlygetRandomValues, andcrypto.randomUUIDthrows because idempotency keys must be UUIDv7. You get the ECMAScript data-processing surface (JSON,Date,Math,Map,Set,RegExp,Intl, typed arrays) plus inert helpers likeURL,URLSearchParams,TextEncoder,TextDecoder,atob, andbtoa. Product capability arrives only through the namespaces.Whatever you
returnis what comes back. Intermediate data stays on the server unless you return it or log it, which is the point: fetch a lot, return a little.Markdown or Markdoc content should be an ordinary string literal. If you must use a template literal, escape embedded backticks, backslashes, and
${sequences.
Because a run composes many calls in one round trip, the natural unit of work is a small program, not a single operation:
const orgs = await organizations.listMine({ limit: 5 })
const target = orgs.organizations[0]
const hits = await documents.search({
organizationId: target.organizationId,
query: "onboarding checklist",
mode: "hybrid",
limit: 5,
})
return { organizationId: target.organizationId, titles: hits.results.map(r => r.title) }Start with guide, then orient inside run
Use top-level guide when you need to learn the workflow. Inside run, the orientation namespace is the identity entry point: orientation.whoami() takes no arguments and returns a bounded identity summary:
return await orientation.whoami()It gives you userId, authMethod ("api_key" or "oauth"), scopes (the OAuth scopes delegated to this connection), organizationCount (how many organizations you hold an active, unexpired membership in), documentationOrganizationId, and a short instructions string. documentationOrganizationId names the organization that holds Oddlynew's own documentation, so documents.* methods can read it directly like any other organization. It is null when the deployment configures none.
guide({ query?, explain?, cursor? }) reads maintained public documentation and returns the readable content directly. query answers a natural-language question. explain takes one namespace name, such as explain: "documents", and returns that namespace's manual: the generated live contract with every method signature first, then the maintained guidance for it. A dotted name like explain: "documents.getDocument" front-loads that method:
guide({ query: "How do I connect Gmail?" })query is optional (1 to 500 trimmed characters) and defaults to getting-started guidance. The result carries { query, content, sources, nextCursor?, fallbackToStart? }. Read content directly; when nextCursor is present, call guide again with that token unchanged. sources provides document provenance rather than requiring another read.
guide answers product, workflow, namespace, and integration questions from maintained public documents. For exact parameters, call the method inside run; a wrong call fails with the live server signature.
Guide teaches the API; server validation is exact
Oddlynew keeps both tool descriptions compact. guide owns maintained workflow and API discovery; run carries the durable foundation and executes the live namespace calls. A client can still cache descriptions, so the server's returned signature is the final authority when a call disagrees.
Ask guide which namespace, method, and workflow apply. A question returns maintained readable guidance. guide({ explain: "<namespace>" }) returns that namespace's generated live contract with exact parameters, followed by its maintained manual, and an unknown name answers with the full namespace index. Then call the method inside run; if a parameter is wrong, the server's returned signature is the authoritative live contract.
Do not rely on a static namespace list in this article. Ask guide which namespace and workflow apply, then call the namespaced method inside run. If the server rejects a parameter, trust the signature in the server error, then correct the call. Do not keep retrying a stale client assumption.
If you guess a method name wrong, the failure teaches you the surface rather than just rejecting you. An unknown method comes back as Tool "<name>" not found. Available: <every callable signature on that namespace>, and the message ends with a pointer to that namespace's manual: guide({ explain: "<namespace>" }).
Authorization is checked on every call, not at discovery
Appearing in the index is not permission to call. Every namespace call is authorized independently, against three planes at once:
Delegated OAuth scopes on your token. Reads need
mcp:read; anything that writes also needsmcp:write.Live organization authority. Active membership in the organization named by the call, plus the roles and enabled features that produce the concrete permission the method requires (for example
org:sys_memberships:readfororganizations.listMembers).Feature and principal restrictions. For instance, API-key principals cannot create organizations; that is an OAuth-only action.
Integrations follow the same scope rule, with a consequence worth naming: a read-only token can observe connected integrations but can never act through them. Each integration enumerates its read methods explicitly; everything else — posting, sending, managing, provisioning on the external platform — refuses without mcp:write, and a method that has not been classified yet refuses as a write rather than passing as a read. What a write may reach externally is bounded a second time by how the organization set up the connection: the access level chosen when connecting an OAuth integration, or the permissions on the API key the organization stored. Granting mcp:write therefore never widens a connection beyond what it was set up to allow.
Follow each generated signature instead of applying one blanket organization-selection rule. Most collection queries, organization-owned creation, and administration methods require an explicit organizationId. setOrganizationVisibility is the deliberate exception: it may omit the id only to use the principal's current default organization. Methods that already receive an exact resource id, such as a document, source, or copy-operation id, may derive the owning organization from that resource. Every path authorizes live access to the organization it resolves.
return await organizations.getMyAccess({ organizationId: "0198f0c1-2b4a-7c31-9d55-8f2e6a1b4c77" })organizations.getMyAccess returns organizationId, roles, enabledFeatures, organizationPermissions (derived from live roles and enabled features), and delegatedScopes. OAuth scopes cap what a connected client may do for a user. An organization API key instead authenticates a dedicated machine principal: the key is only a credential, and effective access comes from that principal's live memberships and roles. Provisioning documents:read assigns the documents_api_reader role, which permits MCP document reads without document creation or management; API-key principals cannot create organizations. When a call is denied, inspect getMyAccess rather than guessing which authorization plane blocked it.
Authorization is resolved live at each call, so an organization created earlier in the same run is usable by later calls in that same run.
Run budgets
Every run executes under fixed limits. The numbers below are the deployment defaults enforced today:
| Budget | Limit |
|---|---|
| Wall-clock timeout | 30 seconds |
| Synchronous CPU per run | 1 second |
| Provider calls per run | 64 |
| Submitted code | 32 KB |
| Arguments per provider call | 512 KB |
| Result per provider call | 128 KB |
| Final returned result | 128 KB |
| Logs | 50 entries, 4 KB each, 24 KB total |
| Error text | 32 KB |
The 30 second budget is wall-clock and covers awaited provider work. The separate 1 second CPU allowance covers synchronous computation in your script: awaiting many calls is fine, grinding through a large in-memory transformation is not. Split heavy work across runs.
The limits behave differently at each boundary:
Code over 32 KB fails before anything runs: "Generated codemode code is too large: N bytes, limit 32768 bytes."
Oversized provider arguments fail before the provider is called: "Codemode provider arguments exceed the 524288-byte limit."
The 65th provider call rejects with "Codemode provider call budget exceeded: 65 calls, limit 64 calls per run.", which fails the run.
An oversized provider result does not fail the run. The call already did its work, so your script receives a replacement object instead of the data:
{ codemodeResultTruncated: true, tool, maxSizeBytes, originalSizeBytes, reason: "provider-result-too-large", guidance, preview }. It is deliberately not shaped like real data, so arithmetic on it yieldsNaNand field access yieldsundefinedrather than a plausible wrong answer.previewis a JSON prefix for inspecting shape only. Re-call with a narrower scope, fewer fields, or pagination.Oversized final results are reduced to fit 128 KiB: objects retain their keys where possible and gain
codemodeResultTruncated: true, arrays retain a prefix followed by a marker withtotalCountandomittedCount, and other values fall back to a preview envelope. If the evaluator reaches its 1 MiB transport or serialization limits first, it returns a flat marker withreason: "result-too-large","result-too-complex", or"unserializable", so check both the top-levelcodemodeResultTruncatedflag and an array's final element, not one reason string. Return summaries, not corpora.console.*output is captured intologsand truncated with an explicit marker when it exceeds the entry, count, or total ceilings.
Fetch data and create documents in the same run
A run can read from a source and create documents from what it read, so the content never passes through your context. The source can be an integration (any namespace with a listSources method, mcp.callTool, api.callOperation), a file in Files up to 96,768 bytes through files.getFileContent, or any other Oddlynew read. Each answer arrives in your script as an ordinary value: shape it into Markdoc there and pass it to documents.createDocument. You write the script and get back ids and counts.
// organizationId, typeId and sourceId come from earlier reads. The creationIds are UUIDv7s
// generated on your own host before the first attempt, one for each document this run may create.
const creationIds = ['019f9a12-4c7e-7a31-9d55-2b6c8f0e4471', '019f9a12-4c7e-7b02-8c41-6d3a9e2f1b58', '019f9a12-4c7e-7c13-a2f0-1e8b4c7d9a03']
const page = await gmail.searchThreads({ sourceId, query: 'label:customer-feedback newer_than:7d', maxResults: creationIds.length })
if (page.codemodeResultTruncated) return { stopped: 'search answer truncated', originalSizeBytes: page.originalSizeBytes }
const results = []
for (const [i, hit] of page.threads.entries()) {
try {
const thread = await gmail.getThread({ sourceId, threadId: hit.threadId })
if (thread.codemodeResultTruncated) {
results.push({ threadId: hit.threadId, skipped: 'truncated' })
continue
}
const markdoc = thread.messages.map(m => '## ' + (m.untrustedDate ?? 'Message') + '\n\n' + (m.untrustedBody ?? '')).join('\n\n')
const doc = await documents.createDocument({
organizationId, typeId, creationId: creationIds[i],
title: 'Feedback thread ' + hit.threadId,
writeClass: 'bulk',
draft: { markdoc },
})
results.push({ threadId: hit.threadId, documentId: doc.documentId, writeConfirmed: doc.writeConfirmed })
} catch (error) {
results.push({ threadId: hit.threadId, failed: String(error).slice(0, 300) })
}
}
return resultsThree rules keep the content on the server and the result honest:
Return ids, never content, and do not log it. Only what the script returns and logs reaches you, and only when the run succeeds: a failed run hands back the answers of its calls in
outcomes. Catch expected per-item failures so the run settles, as the example does, and return ids, counts and per-item status.Check every answer for truncation before you write from it. An answer over its size limit arrives as a
codemodeResultTruncatedenvelope instead of data, and a document built from it is wrong without any error. Branch oncodemodeResultTruncatedand narrow the call (fewer results, the next page, a smaller file) instead of writing.Generate every
creationIdon your own host before the first attempt. Several creates in one run are not one transaction: a run that fails after the second of five creates keeps those two documents. With the ids as literals in the script, running it again returns the documents that already exist (the retry's draft is ignored) and creates only the missing ones. Each id must reach the same organization, type and title on every attempt, so pair it with a stable item and build the title from stable data; a different title under an existing id is refused. Only a host that cannot generate a UUIDv7 callsids.mintOperationIds, in a run of its own.
The budgets above set the size of one run. Most namespaces answer up to 128 KB per call; the whole createDocument call, Markdoc included, may be up to 512 KB, so one document from one create holds a little under 512 KB of Markdoc. One read and one create per item take two of the 64 calls, so one run creates at most about 30 documents, and fewer when each create is large, because the run must also finish within 30 seconds. Continue with the next page in a further run, with fresh ids for the new items.
The same recipe works for files and URLs. A text file up to 96,768 bytes is read in the same run with files.getFileContent and encoding: 'text'. Longer text goes through several parts joined on the server, as Long text into a document describes. A public URL first becomes a file with files.ingestFileFromUrl; once files.getFileIngestion reads completed, a later run reads the file and creates the document.
Reading the result
A run comes back as { code, result, error?, logs?, calls? }. code is always an empty string; it is kept only for clients that expect the field, so never read your script back from it. A successful run puts your returned value in result. A failed run puts its public failure message in error; some clients display its unusable result as null. logs carries bounded console output, and a failed run keeps the console output written before the failure. Whenever provider calls ran, calls carries the host-recorded ledger; a zero-provider run can omit it. Always check error before reading result. The tools advertise no output schema, so treat this shape as documentation, not as a contract a client validates.
One class of failure never produces that envelope. A refusal that stops the run before it starts, such as the throughput denial Too many run starts: at most X per minute and Y per hour. Retry in N seconds. (or ... in the current throughput window ...), comes back as an MCP tool error carrying only its message, so there is no result, no logs, and no calls ledger to read.
An uncaught namespace rejection ends the run and appears in the envelope's error field. Expected per-item provider failures are catchable with try / catch: handle them deliberately, return an honest partial result, and inspect the failed_but_handled ledger row. Always await provider calls. Fire-and-forget calls and dispatcher transport failures remain fatal or outcome-unknown, and calls completed before an uncaught failure may already have taken effect. The full failure model and retry rules are in Errors, receipts, and safe retries.
The provider-call ledger
Whenever a run invoked providers, the result carries calls, a host-recorded provider-call ledger. Each row is { tool, outcome, count }, where tool is the qualified namespace.method name and outcome is succeeded, failed_but_handled, or in_flight_unknown. This ledger is host evidence; values returned by sandbox code cannot forge it.
Read it precisely, because the honest reading is narrower than it looks:
succeededmeans the provider function returned a definitive server answer; it does not by itself prove that a requested mutation persisted.failed_but_handledmeans the provider rejected, your awaited code caught that rejection, and the script still settled successfully. Treat the fallback or per-item failure you returned as the result.in_flight_unknownmeans the run ended before the provider reported an outcome. This is the genuinely ambiguous case: read current state before retrying.
Only in_flight_unknown leaves an open outcome question. Before retrying one of those calls, read current state and check; a timeout can happen after a write.
Expected provider failures are catchable
Use try / catch when a provider failure is an expected part of a bounded workflow, such as continuing a per-item sweep after one inaccessible item. A handled rejection remains visible in calls as failed_but_handled, so the returned partial result cannot silently look like a fully successful sweep.
Do not catch failures merely to erase them. Return enough per-item status for the caller to understand what was skipped, and leave unexpected failures uncaught so the run fails visibly. Await every provider call; a fire-and-forget rejection cannot be recovered safely.
An oversized provider result is the one exception and is not a failure: the call did its work, so it resolves with the truncation envelope described above and the run continues.
Idempotency: mint your ids outside the retry
Because a timeout can land after a write, creating methods accept a caller-minted UUID version 7 as an idempotency key. Reusing the same key on a retry returns the existing object instead of creating a duplicate.
documents.createDocument({ ..., creationId }): an optional UUID version 7 used only as an idempotency key; a version 4 value is rejected. The server mints the document id separately. A retry with the samecreationIdreturns the existing document, and the retry'sdraftis ignored.session.focus({ ..., creationId }): required when creating a lens, and only valid when creating.organizations.planDocumentCopy({ copyId, sourceDocumentId, destinationOrganizationId }):copyIdis a caller-generated UUIDv7 used only for idempotency; a version 4 value is rejected. Apply the plan withorganizations.applyDocumentCopy({ operationId, planToken })and read the durable receipt withorganizations.getDocumentCopyReceipt({ operationId }).
For caller-owned idempotency keys, run async () => await ids.mintOperationIds({ count: 1 }) as a separate preparation run. It must be the first and only provider call in that run. Pass a larger count to mint a batch in one preparation run when a plan needs several keys, such as a paging loop. Preserve the returned UUID v7 outside the sandbox, paste it as a literal into the mutation run, and reuse it only when retrying that same intended operation. A UUIDv7 generated by the host is also valid. This applies to caller-owned fields such as creationId, copyId, and connector mutation operationId; never replace a platform-returned operationId, such as the snapshot-copy operation returned by organizations.planDocumentCopy.
return await documents.createDocument({
organizationId: "0198f0c1-2b4a-7c31-9d55-8f2e6a1b4c77",
typeId: "0198a2d4-77b1-7e02-b0c9-3f5d9a1e6b24",
creationId: "0199d1a4-6f1c-7a5e-9f10-7c2e5b8d4a61",
title: "Q3 onboarding checklist",
draft: { markdoc: "# Q3 onboarding\n\nFirst draft." },
})Do not derive the key from content, and do not reuse one across different objects. For methods with no idempotency key, read current state before retrying.
Sharp edges worth remembering
Discovery and authorization are separate systems.
guidedescribes the public surface and workflows; your delegated scopes, live memberships, and enabled capabilities decide what actually runs.Search indexing lags writes by a few minutes. A document created moments ago may not appear in
documents.searchyet; usedocuments.listDocumentsor retry shortly.succeededin the ledger is a lower bound, never a persistence guarantee.Truncation envelopes are designed to break loudly. If your arithmetic suddenly produces
NaN, you are computing on one.Each
runinvocation gets its own run identity and its own freshly resolved providers, so budgets are never shared between runs, including runs batched into one request.