My co-founder and I manage our work in ClickUp and a calendar app we built ourselves. We’re proud of our glorified calendar, but it’s new and has no integrations. So we copy tasks between it and ClickUp by hand several times a day.
Eventually we got tired of copying tasks and sat down to connect them.
The workflow sounded simple:
sequenceDiagram
participant ClickUp
participant Mine as My calendar
participant Cofounder as Co-founder's calendar
ClickUp-->>Mine: Sync in-progress tasks
Mine->>ClickUp: Complete task
Note over ClickUp: Task moves to in review
ClickUp-->>Cofounder: Sync in-review tasks
Cofounder->>ClickUp: Complete task
Note over ClickUp: Task moves to done
Notice that “complete” means something different depending on who clicks it. For me, it means the work needs review. For him, it means the work is finished. Same button, two meanings, both correct.
Our first attempt was to build a ClickUp integration directly into the calendar.
The API calls were straightforward. Fitting ClickUp into the calendar was not:
- ClickUp stores tasks in spaces, folders, and lists. We wanted tasks from only a few lists, so the calendar would need a ClickUp-shaped selection screen.
- Every list can define its own statuses. Someone would have to map them onto the calendar’s simple
openanddone. - Completion depended on who acted. The same
completeaction meantin progress -> in reviewfor me andin review -> donefor my co-founder.
No single decision was fatal. The problem was where they led. The calendar would acquire a ClickUp-shaped settings screen and ClickUp-specific types and conditionals throughout its code. And that was only ClickUp. Every new source would bring another configuration model, another set of edge cases, and another product’s concepts into the calendar.
Building the integration into the calendar would pull in two kinds of knowledge that did not belong there: how ClickUp worked, and how we wanted it to fit our workflow. The first was source knowledge; the second was workflow knowledge. Calendar’s team should not have to understand every external product or encode every user’s workflow. Once embedded in Calendar, changes to either would require changes to Calendar too.
We happened to be both the developers and the users, so we could hardcode our workflow into the calendar. But then every user would inherit it, and theirs would be different.
We could build our ClickUp integration. We could not ship it as everyone’s ClickUp integration.
The thing is, our calendar already has its own task model: a title, a due date, and a state such as open or done. Any external task has to enter Calendar in that form. ClickUp spaces, folders, lists, and custom status types have no place in that model. Calendar does not even care whether the tasks come from ClickUp.
The conventional response is to expose a public API and let users or developers build their own integrations against it. That puts each integration in the hands of the person who understands the workflow — and lets them customize it however they want.
A common way to connect that public API to ClickUp is trigger-action workflow automation. A workflow would listen for changes in ClickUp and push them into Calendar. Calendar would receive the updates configured in the workflow, at the times determined by its triggers.
Receiving an update is only the beginning. Calendar keeps a local copy of each task and connects it to Calendar-only state. A ClickUp task, for example, may have several scheduled occurrences that exist only inside Calendar. Keeping the task and its occurrences consistent requires more than processing updates as they arrive.
On first run, Calendar needs the full current set of tasks. After being offline, it needs everything that changed or was deleted since it last synchronized. After an interrupted sync, it needs to resume without duplicating work. When someone clicks complete, Calendar needs to know whether ClickUp accepted the command or rejected it, perhaps because the task changed after it was last synchronized. Without those answers, Calendar cannot keep its tasks and scheduled occurrences consistent.
An external system can tell Calendar that something changed. But it cannot see inside the app, so it cannot know whether the app is missing data or its local state needs repair. Calendar is responsible for keeping its tasks and scheduled occurrences consistent, so it must drive the exchange that keeps them that way: initiate synchronization and commands, inspect their results, and apply its own logic. We named this control-loop ownership.
A workflow can imitate each step with triggers and actions, but it does not naturally give Calendar one durable interface to call whenever its state needs repair. If the workflow stores cursors, serves reads, accepts commands, returns conflicts, and supports recovery, it has become a server inside the workflow tool. That server, not the trigger chain, is what Calendar needs.
Calendar is also a native desktop app, so outside services have no clean public endpoint to call without adding a hosted relay, tunnel, or local agent. The natural direction is the reverse: Calendar should call out to an integration server, not wait for integrations to call in.
The integration logic still had to come from somewhere. It should come from the person who owned the workflow, not Calendar’s team. Each user should be able to provide an implementation tailored to their tools and workflow.
For Calendar to use that implementation directly whenever it needed to synchronize or act, the implementation had to be available as a durable server. The server would translate Calendar’s requests into operations on the user’s other apps and return the results.
That created another problem. The implementations could differ completely, but Calendar could not use a different API for each one without accumulating custom integration code again. Every server would need to expose the same API. Calendar could define that API in a contract.
One client, written once, could work for any user.
The interface begins with what Calendar needs, not what ClickUp offers. We had arrived at a client-defined server API.
Our Calendar contract might ask for:
GET /tasks/sync
-> tasks, deletions, next_cursor
POST /tasks/{id}/complete
{ basis_version }
Task {
id
title
due_date
state: open | done
version
}
Notice what the contract does not say: ClickUp. The calendar wants tasks. Where they live is not its business.
A contract specifies exactly the server API a client needs. The resource owner decides whether and how to provide it.
The contract fixed the interface, but it did not make the implementation callable. Some software still had to run that implementation and expose a conforming server on the resource owner’s behalf. We named that role a mediator.
The result still behaves like a native integration. Calendar initiates synchronization and commands, interprets the results, and applies its own product logic. Only the source- and workflow-specific implementation is delegated.
Once we separated the interface from its implementation, the idea no longer seemed exotic. Software hides implementation details behind interfaces all the time. We were applying the same separation across an application boundary: the client defined the server interface, while the resource owner provided its implementation. That interface could describe any server API the client needed.
A custom integration service could act as the mediator and implement our contract exactly. But its owner would also inherit hosting, authentication, secrets, pagination, cursors, retries, rate limits, deduplication, recovery, monitoring, and deployment. The mapping is small. The infrastructure around it is not.
Providing an implementation does not mean writing or hosting everything yourself. A resource owner might write it, generate it, or choose one supplied by someone else. A mediator could run it locally, be shared by a team, or be operated by a provider, while the resource owner still controlled how the contract was implemented. Reusable mediator infrastructure could share the operational machinery across many contracts.
Could the knowledge we had moved out of Calendar be reused too? Some of it. Source knowledge could be shared across contracts: communicating with ClickUp works the same regardless of the workflow. Workflow knowledge remained specific to us: which lists to use, how statuses mapped, and what completion meant for each person. We named the reusable source-specific code an adapter and the workflow-specific code a binding.
Adapters and bindings were one practical way to make an implementation easier to reuse and maintain, not a requirement of the contract model. Each binding served one workflow, so it could remain small. Neither component was visible to Calendar. It interacted only with its contract API. Changes to our workflow now stopped at the binding instead of propagating into Calendar.
The division was between client and resource owner. Calendar’s team retained the interface, control loop, and product behavior. The resource owner controlled the implementation connecting that interface to their tools and workflow.
When Calendar sends complete, the mediator returns an answer: the resulting task, a rejection, or a conflict if the source has changed. The same command can apply in progress -> in review when I send it and in review -> done when my co-founder sends it, because the binding knows what completion means for each of us.
flowchart LR
subgraph Client["Client side"]
Calendar["Calendar"]
Contract["Contract"]
Interface["Required API"]
Calendar -. "authors" .-> Contract
Contract -->|"defines"| Interface
end
subgraph Mediator["Mediator implementation"]
API["Conforming server API"]
Binding["Binding"]
Adapter["ClickUp adapter"]
API --> Binding --> Adapter
end
ClickUp["ClickUp"]
Interface -. "implemented by" .-> API
Calendar -->|"calls"| API
Adapter -->|"reads and writes"| ClickUp
The inversion was not about synchronization. It was about who defines the interface. A client can define the server shape it needs, while the resource owner controls its implementation. The same approach can support collections, queries, commands, streams, or ordinary CRUD. Producer-defined APIs remain the right choice when one producer owns the service and its canonical interface.
From a definition to a protocol
A contract is portable only if clients and mediators share a way to negotiate it. There is no reason to force every client into one API standard. A client can describe the API it needs using the standard that suits it best, while each mediator declares which standards it supports.
What needs to be common is the higher-level exchange around the contract: the client submits it, the mediator reports when a conforming server is ready or why it cannot provide one, and the client can end that relationship when it no longer needs it.
The protocol that emerged is Treaty.
In Treaty, each submission creates a contract instance whose lifecycle state the mediator exposes to the client.
The exact wire format is still being specified. A current illustrative contract envelope looks like this:
contract_id: nodify.calendar.tasks
revision: 2
client:
id: nodify.calendar
name: Calendar
purpose: Show tasks in Calendar and let users complete them.
api_standard:
id: intra.openapi-profile
version: 0.1-draft
The complete API document accompanies the envelope. Once the mediator makes a conforming server available, the contract instance might become:
contract_instance_id: ci_123
generation: 2
state: ready
server:
base_url: https://intra.example/ci_123
effective_scope:
operations:
- sync_tasks
- complete_task
Calendar can now call the paths defined in its API document using that base URL. Treaty negotiated the relationship. The selected API standard governs the resulting server and its calls. Different mediators can implement the same contract differently, provided they support that standard and expose conforming servers.
We will publish Treaty as an open protocol. We are also building Intra, a software integration platform, with Treaty as its first supported protocol. Intra’s v1 starts with the pattern we needed here: synchronizing simple collections into an application’s cache and routing explicit commands back to the source. Our calendar is its first client.
Client-defined APIs could have existed earlier, but for shallow integrations an in-app catalog is hard to beat. Click a logo, approve access, and continue. Deeper, user-specific integrations traditionally required both custom logic and a custom service around it. A mediator lets many contracts share that operational machinery, while AI is making the remaining binding code much cheaper to write and maintain. The architecture becomes economical precisely where catalogs stop being expressive enough.
In short: the client retains the interface, control loop, and product behavior. The resource owner controls how that interface connects to their tools and workflow. A contract makes the interface explicit. Treaty makes it portable and negotiable. Intra provides the machinery to implement and operate it.
Integration catalogs will remain useful defaults. They just no longer have to be the boundary of what an application can support.
Product teams should not have to build every native integration. They can define one integration interface and let users provide the implementations they need.
APIs have traditionally begun with what servers offer. They can also begin with what clients need.
P.S. What about the alternatives?
Integration catalogs remain the easiest answer for common, shallow integrations. Their limit is authorship: an application’s developers cannot anticipate every user’s workflow. A mediator can still offer a catalog of popular bindings; contracts keep that catalog a convenience rather than a ceiling.
Public APIs are the usual way to let others extend an application. They remain essential: a mediator normally reaches external tools through the APIs they already expose. But a producer-shaped API tells integrators what a server offers. It does not give a client the stable server interface a particular client needs.
Workflow automation tools are a popular way to bind public APIs together. They are excellent at reactions: when this happens, do that. With enough state and custom code, they can reproduce much of a server, but then the server surface, not the trigger chain, is doing the important work. A workflow platform could act as a Treaty mediator if it exposed the contract API and satisfied the selected API standard. Local-first apps expose another mismatch: many workflow platforms assume a reachable hosted endpoint or outgoing webhook, so a relay, tunnel, hosted component, or local agent may be required before automation begins.
Sync services come closest to Intra’s v1 pattern. They keep records aligned and often provide useful mapping rules. But they remain bounded by the sources and configuration vocabulary their vendor supports. A sync service could act as a mediator when its model is sufficient. Treaty makes the contract portable, not the binding code, so another compatible mediator can implement the same contract without changing the client.
Custom integration services offer complete freedom, but their owners must build and operate the surrounding infrastructure. A custom service can itself act as a Treaty mediator; doing so gives the client a portable contract API and gives the service an explicit conformance target.
MCP lets servers expose tools, resources, and prompts for clients to discover. Treaty starts from the other direction: the client defines the server interface it needs, and a mediator implements it. The two can complement each other; an MCP server could sit behind a Treaty binding.
API contracts usually describe an interface a producer already offers. A Treaty contract specifies a client-defined interface and becomes operative only when a mediator exposes a conforming server to the client on the resource owner’s behalf.
Consumer-driven contract testing records consumer expectations and checks whether a producer-shaped API still satisfies them. Under Treaty, the client’s shape is not a test against the API. It is the API.
GraphQL lets a client select from a schema published by a producer. That is useful, but the possibility space remains producer-defined. Treaty lets the client define the interface, while the resource owner chooses whether and how to provide it.
API standards and formats. Treaty does not prescribe a single API format. The Intra OpenAPI Profile, based on OpenAPI 3.1, is the first standard Intra supports; other mediators may support others.