LibraryOrganizations and membershipv20 · Updated Report

Organizations and membership

What an organization is

An organization is the unit of ownership in Oddlynew. Documents, integrations, members, permissions, and visibility all belong to exactly one organization, and nothing crosses that line implicitly. Reads always resolve the owning organization and authorize against its current audience.

A private organization's audience is its active membership; public visibility widens reading beyond membership. In a public organization anyone can read every document at its canonical URL on oddlynew.com, with or without an account, and sees the latest saved version there. Reads through a connected agent need an account, are paid on the reader's own account, and also see edits before they are saved, and earlier versions. Organization-scoped keyword search also works without an account, within a rate limit. Writes and administration remain membership-controlled, and the organization remains one audience boundary.

Everything below happens inside run, in the organizations namespace. Every call is authorized separately against your delegated OAuth scopes (mcp:read, mcp:write) and your live roles and enabled features in the target organization. A method appearing in guidance is not an authorization grant.

Trust and data boundaries in plain language

Who can see and change an organization

Oddlynew's permission model is deliberately organization-wide. A private organization's working audience is its active membership, and active members have broad access to its documents and document types. An administrator has additional membership and organization-management powers. Document-level permissions are outside the product model, not a feature waiting to be added. Use a separate organization whenever someone must be excluded from part of the work.

Think of an organization as a bubble of knowledge that one audience can use together, not as a folder from which individual documents are selectively shared. Retrieval, relationships, correction, adaptation, and expansion make the corpus cumulatively more valuable. Capacity and pricing follow this same organization boundary.

Changing an organization to public widens the reading audience for its persisted documents. It does not make public readers members, and it does not grant them write or administrative access. Treat that change as publication, not as an ordinary sharing toggle.

What a connected agent can reach

An Oddlynew OAuth connection is currently user-scoped across every organization the connected user belongs to now or joins later. Its mcp:read and optional mcp:write scopes cap the user's live role permissions. An organization API key is different: it authenticates a dedicated machine principal, and the key itself carries no authority. Effective access comes from that principal's live memberships, roles, permissions, and enabled features. Provision documents:read for a read-only document integration; it assigns documents_api_reader, which permits MCP document reads without document creation or management. API-key principals cannot create organizations.

A user-scoped connection can therefore support a higher-level maintenance agent that inspects several organizations the user can access. That does not grant cross-organization write authority as one bundle. The agent must preserve each organization's membership, visibility, local guidance, and permissions, and every mutation is authorized against the organization it changes. Use this pattern for cross-organization gardening or portfolio review without treating several organizations as one security or audience domain.

When an agent reads content, Oddlynew returns the requested material to the user's chosen client and model provider. At that point the data has crossed from Oddlynew's storage boundary into the user's selected AI environment, whose contracts, retention, training, and location terms also matter. Connecting an agent is therefore a data-access decision, not only a convenience feature.

External integration data follows the same rule. The provider call checks access to the organization that owns the connection. If an authorized person or agent then writes a result into a document, the document's organization determines who can read that stored result.

Where Oddlynew keeps data

Oddlynew keeps authoritative organization, membership, document, and type state in its managed application storage. Live collaboration and durable document versions use additional operational storage. Search maintains derived text chunks and embeddings so agents can retrieve relevant passages. Files and connected integrations have their own storage and provider boundaries.

These representations do not all live in one place or with one provider. The privacy notice is the binding answer: section 13 lists our service providers and section 14 the retention periods. In short, the database runs at PlanetScale in the EU (Frankfurt) and files are stored at Cloudflare in the EU. Requests reach Oddlynew through Cloudflare's worldwide network, and several providers are companies in the USA; such transfers rest on the EU-US Data Privacy Framework or on standard contractual clauses. What a connected agent reads goes to the AI service you chose, under that service's terms. A data processing agreement for business customers is not published yet. Questions and data requests go to office@oddlynew.com.

Practical rule for sensitive or regulated work

Before using Oddlynew for patient data, study-participant data, protected health information, confidential unpublished research, or another regulated category, the responsible institution should approve the exact data flow, member set, connected agent and model providers, contracts, retention, deletion, and incident process. Until that review exists, keep regulated data out and use only material approved for the environment.

Organization boundaries help enforce an approved audience, but they do not replace a DPA, ethics approval, information-security review, clinical-system requirements, or applicable law.

Creating an organization

const created = await organizations.createOrganization({ name: "Field Notes" })
return created

Returns { organizationId, name, nextSteps }. The name must be 1 to 100 characters; an empty or over-long name is rejected by parameter validation before anything is written.

You become the organization's administrator (org_admin), and the organization is created with the generic organization type, exactly as if you had created it in the web app.

Two constraints are worth knowing before you plan around this method:

  • It requires mcp:read and mcp:write.

  • API-key principals cannot create organizations. The refusal is explicit: McpAuthorizationError (api_key_cannot_create_organization) for an organization key, or (personal_key_cannot_create_organization) for a personal key. An API key's whole authority is the set of organizations it was provisioned into, so it cannot found one it would then administer. Create the organization in the web app or over a delegated OAuth connection, then provision the key into it.

Organization authorization is checked live at every call. On an OAuth connection covering all organizations, later calls in the same run script can use the returned organizationId immediately; on a connection limited to selected organizations, it becomes available from the next run, without reconnecting. organizations.listMine includes it on the next request with no reconnect:

const org = await organizations.createOrganization({ name: "Field Notes" })
const access = await organizations.getMyAccess({ organizationId: org.organizationId })
return { organizationId: org.organizationId, roles: access.roles }

Finding organizations and checking your access

organizations.listMine paginates the organizations you currently belong to. limit defaults to 20 and caps at 100; query is an optional case-insensitive name search.

const page = await organizations.listMine({ query: "field", limit: 20 })
return page.organizations.map(o => ({ id: o.organizationId, name: o.name }))

Each entry carries organizationId, name (which may be null), and a lightweight integrations array of names only. It answers which connector kinds are present without leaking account identity or folding lifecycle state into the directory. Use the relevant integration's paginated listSources method when you need concrete accounts, display values, or status. Paginate organizations with cursor until hasMore is false.

organizations.getMyAccess({ organizationId }) returns both authorization planes for one organization: roles, enabledFeatures, organizationPermissions (derived from roles intersected with enabled features), and delegatedScopes (what your token carries). When a call is denied, read this rather than guessing which plane blocked you.

organizations.renameOrganization({ organizationId, name }) changes only the display name. It uses the same 1 to 100 character bound and additionally rejects surrounding whitespace, and it requires mcp:write, an administrator role, and org:sys_profile:manage.

Public and private visibility

organizations.setOrganizationVisibility changes an organization-wide audience boundary. It requires mcp:write, an administrator role, and org:sys_profile:manage. Pass organizationId explicitly when choosing among organizations; this method may omit it only to use the principal's current default organization. Setting visibility to public makes every current persisted document readable without membership and without an account at its canonical URL, and through account-backed MCP with delegated mcp:read; it also enables anonymous organization-scoped keyword search. Editing, membership, and administrative operations remain protected.

return await organizations.setOrganizationVisibility({
  organizationId: "019fa9e6-4e99-7ba2-b8e1-ac8798b017ca",
  visibility: "public",
})

The receipt includes previousVisibility, visibility, and readableInboundCrossOrganizationCitations. Retrying the same target visibility is idempotent. You can later set visibility back to private, but readers may already have retained anything exposed while the organization was public.

Listing members

const page = await organizations.listMembers({
  organizationId: "019fa9e6-4e99-7ba2-b8e1-ac8798b017ca",
  limit: 100,
})
return page.members.map(m => ({ id: m.membershipId, name: m.displayName, status: m.status }))

Each member row is { membershipId, userId, displayName, email, phoneNumber, roleKeys, status, expiresAt, createdAt }. userId is null until a user account is linked to the membership; an invitation created by inviteMember always starts with a null userId, but do not treat userId as a status flag: read status instead. limit defaults to 100 and caps at 1000; paginate with cursor until hasMore is false.

The list covers active members and pending invitations: returned status values are active, invited, and provisioned. Declined and revoked memberships are not returned.

Rosters carry personal data, so listMembers requires mcp:read and the org:sys_memberships:read permission, the same permission the product's team page demands. Bare membership in the organization is not enough.

Inviting, revoking, removing

All three mutations require mcp:write, an organization administrator role, and a membership permission (org:sys_memberships:create for invites, org:sys_memberships:delete for revocations and removals).

inviteMember

const invite = await organizations.inviteMember({
  organizationId: "019fa9e6-4e99-7ba2-b8e1-ac8798b017ca",
  email: "dana@example.com",
  invitedName: "Dana Okonkwo",
  roleKey: "member",
})
return { membershipId: invite.membershipId, inviteUrl: invite.inviteUrl }

roleKey is reader (Reader: reads documents, files, published content and activity, and changes nothing), member (Writer: reads, creates and edits documents) or org_admin (Admin: everything, including members, keys and deletion). invitedName is the recipient-facing display name. The optional expiresAt is an ISO timestamp after which membership access expires; it is separate from the invitation itself. notify: false creates the invitation without sending the email.

The current MCP result is { membershipId, email, roleKey, status: "invited", expiresAt, inviteUrl, emailSent }.

Important behavior:

  • An authorized agent can create an invitation and production currently sends the invitation email automatically. inviteMember also returns the single-use inviteUrl. This was runtime-verified on 25 August 2026 in the dedicated Invite Flow Verification organization: the recipient received the branded Oddlynew invitation email immediately after the MCP call. The result's emailSent says whether the email went out.

  • An invitation is a real audience change, not housekeeping. Before calling inviteMember, confirm the exact organization, recipient, email address, and role. The pending invitation does not become active organization access until the recipient accepts it.

If an invitation write has an ambiguous failure or timeout, inspect the current member and invitation list before retrying. inviteMember has no caller-supplied idempotency key, so do not blindly issue a second invitation.

What the invited person needs

The recipient does not need MCP or a connected agent merely to read shared work. After signing in and accepting the invitation, they can use Oddlynew's normal human document surface for the organization they joined. They can read the documents they are authorized to access and participate through the available human collaboration features.

Connecting ChatGPT, Claude, or another compatible agent is optional. It should be offered later as extra leverage, not imposed as the price of opening something a colleague or partner shared.

revokeInvitation and removeMember

These are split by kind and are not interchangeable. revokeInvitation acts only on a pending membership (invited or provisioned); removeMember acts only on an active one. Both take { organizationId, membershipId } and return { membershipId, revoked: true } or { membershipId, removed: true }.

return await organizations.removeMember({
  organizationId: "019fa9e6-4e99-7ba2-b8e1-ac8798b017ca",
  membershipId: "019fa9e7-1c40-7a11-9d02-6f3c11a4e2b8",
})

Calling the wrong one, or acting on a membership whose status has changed, fails with the membership-state conflict ("The requested change conflicts with current membership state..."); a membership that no longer exists fails with McpOrganizationStateError (membership_not_found): "The membership no longer exists. Refresh with listMembers and retry." Either way, refresh with listMembers and decide again.

The last administrator cannot be removed. removeMember preserves at least one non-expiring active membership holding an administrator role. That refusal, and an invite that collides with an existing membership, both surface as a conflict: "The requested change conflicts with current membership state. The person may already be a member, or the change may remove the last administrator. Refresh with listMembers before retrying."

A cross-organization gardener may conclude that material would be more useful somewhere else. Treat that as a semantic proposal, not an implicit move. Depending on the intended ownership, the right action may be to add a reference, rewrite the idea for another audience, create an independent copy, or leave the source where it is. Crossing the boundary must remain explicit because ownership, membership, lifecycle, and authority change with the destination.

Copying a document into another organization

Because organizations are hard boundaries, moving content between them is an explicit, audited operation rather than a side effect. The snapshot-copy lane is deliberately narrow: it produces one independent copy of one document, and it refuses anything it cannot prove is portable.

It is a two-step protocol (document-snapshot-copy-v1) with a durable receipt: planDocumentCopy, then applyDocumentCopy, with getDocumentCopyReceipt for recovery and audit.

planDocumentCopy

return await organizations.planDocumentCopy({
  copyId: "019fa9e6-8b30-7d21-9c44-3e5f6a7b8c9d", // a UUIDv7 generated on your host
  sourceDocumentId: "019fa9e6-8b31-7c55-a0d4-2b7c9e4f1a33",
  destinationOrganizationId: "019fa9e6-4e99-7ba2-b8e1-ac8798b017ca",
})

Planning writes no target document. It authorizes, evaluates eligibility, pins the exact source version, and persists a receipt.

  • copyId is a caller-minted UUID v7, used only for plan idempotency; a v4 value is refused. Mint it with ids.mintOperationIds in a separate preparation run (or in the host), preserve it outside the sandbox, paste it as a literal, and reuse it only when retrying the same plan. The later operationId and planToken are returned by organizations.planDocumentCopy; reuse those platform-authored values unchanged.

  • Exactly one source per call, and one destination. The server derives the source organization from the document and authorizes it; you do not pass it.

  • The destination must differ from the source organization, otherwise the call fails with same_organization.

  • Authorization: mcp:read and mcp:write, current read access to the source document, and document-create permission (org:document_management:create) in the destination.

Eligibility and blockers

Snapshot-copy v1 accepts only a current Markdown or AGENTS.md platform type whose source is clean and unencumbered. Files the document references, such as images, are copied with it into the destination organization and count against its file capacity. Anything else comes back as a blocked receipt with a blockers array. Each blocker is { code, message, resolution }:

coderaised when
source_archivedthe source document is archived
source_type_unsupportedthe type is not one of the two supported platform types, or the persisted snapshot's type does not match the document's current type
source_not_cleanthe editor has unsaved or unsettled state, or the exact persisted snapshot or its checksum is unavailable
outside_image_unsupportedthe source displays an image from an outside address; import it with files.ingestFileFromUrl, replace the image source with the imported file reference, and create a new copy plan
metadata_unsupportedmetadata falls outside the portable allowlist
document_references_unsupportedthe content contains structural document references
asset_references_unresolveda file the content references does not resolve in the source organization
asset_file_too_largea referenced file is over the size limit for its type
asset_capacity_exceededthe referenced files would exceed the destination organization's file capacity
content_association_unsupportedthe document is attached to a content post
settings_unsupporteddocument settings are non-empty

The metadata allowlist is small and literal. Absent metadata is portable. Otherwise, the only permitted key is language, and if present its value must be "en". Any other key, including application-specific fields, blocks the plan.

A blocked plan records no source snapshot and can never be applied. Fix the source, then plan again with a new copyId: replaying an existing copyId returns the stored receipt as it was recorded and does not re-evaluate eligibility. Reusing a copyId with a different source document or destination fails with copy_id_conflict.

applyDocumentCopy

return await organizations.applyDocumentCopy({
  operationId: "019fa9e8-2d70-7f41-b6a9-51e7c0b83d10",
  planToken: "3f2a...64 lowercase hex characters...c1",
})

Apply is the confirmation boundary and is synchronous: it creates the document under the destination organization's ordinary document permissions. Pass operationId and planToken back exactly as the plan returned them; a wrong or stale token fails with plan_token_mismatch.

Each request makes at most one target-creation attempt, always against the platform UUIDv7 target id minted at plan time, so a retry after a timeout cannot produce a second document. Before creating anything, apply re-verifies the pinned source against the manifest: an envelope fingerprint over title, metadata, and type, and a semantic fingerprint over the serialized content and settings. If the source changed after planning, if it acquired a content-post association, or if the type is no longer eligible, the operation is terminated as failed with failure code verification_failed and retryable: false. A transient failure instead returns the plan to ready with failure code apply_retryable, retryable: true, and a refreshed 24 hour deadline.

Receipts

Every one of the three methods returns the same receipt shape: operationId, copyId, protocol, state, source { organizationId, documentId, versionId, version, checksum, typeId }, destination { organizationId, documentId, versionId, version }, planToken, blockers, copiedAssets (how many referenced files the plan copies; null when there are none), failure, planExpiresAt, completedAt, retryAfter, nextAction, and disclosure. When state is completed, destination.documentId is the new document's stable id; use that exact value for follow-up reads and writes.

state is one of blocked, ready, applying, completed, failed, expired. planToken and source.checksum are lowercase 64-character SHA-256 hex. The envelope and semantic fingerprints are computed the same way, but they stay server-side: the receipt never returns them.

nextAction is the teaching field: read it instead of writing your own state machine.

  • apply for a ready plan with no prior failure

  • retry_apply for a ready plan that recorded a retryable failure

  • wait_then_retry_apply while applying, in which case retryAfter carries the lease deadline (the lease is 60 seconds) and is null in every other state

  • done when completed

  • create_new_plan for blocked, failed, and expired

getDocumentCopyReceipt({ operationId }) requires mcp:read plus current read access to the source document and the destination organization, and only the operation's creator can read it. It never runs, resumes, expires, or otherwise advances work; there is no background copy pipeline. The only method that advances an unfinished copy is applyDocumentCopy.

A ready plan expires 24 hours after it was created. Expiry is swept in the background and is also applied when you try to apply a stale plan, which returns state expired. Once apply has started, the receipt and the pinned source version remain recoverable past that deadline.

What a copy is and is not

The disclosure field states it on every receipt, and it is worth internalizing: the result is an independent document under the destination organization's permissions. Later edits, deletion, archival, or retraction of the source do not propagate. History, backlinks, posts, attachments, ACLs, activity, and publication state are not copied, and the source is never modified. Plain external links are retained verbatim but are not validated or copied.

Oddlynew PublicSign in

Referenced by