Resource contracts with doc
Use doc when a JavaScript resource has a real contract that should be visible to both humans and tooling.
This is especially useful when you want the resource to be:
- easier to understand without reading the implementation first
- safer to reuse across workflows
- easier for AI tools to interpret correctly
- validated consistently at runtime
What doc is for
The doc object lets a resource declare:
description(...): what the resource is forinSchema(...): the expected input body shapedataSchema(...): the expected processordatashapeoutSchema(...): the output body shapeasUserErrors(): whether schema failures should be treated as user-facing input errorsrun(...): the actual implementation
The goal is to make the contract explicit before the code runs.
Why this is a specific opscotch problem
This matters more in opscotch than in many ordinary application codebases because processor resources are often reused as small runtime components across steps, workflows, and apps.
Those resources usually interact through dynamic JavaScript patterns such as:
context.getBody()context.getPassedMessageAsString()context.getData(...)context.setBody(...)context.sendToStep(...)
JavaScript is inherently vague here. Without an explicit contract, the shape of the input, configuration, and output is often only implied by the implementation.
That creates a real opscotch problem:
- resources are loaded and reused like building blocks
- workflows compose them without strong static typing
- readers often need to understand a resource from its file alone
- AI tools need to infer intent from dynamic code unless the contract is declared
In other words, the more opscotch encourages resource reuse, the more important it becomes to make the interface explicit instead of leaving it buried in JavaScript control flow.
Why use it
Without doc, a reader or AI often has to infer the contract from:
context.getBody()context.getPassedMessageAsString()context.getData(...)- header lookups
- handwritten validation logic
That makes resources harder to reason about and easier to misuse.
With doc, the resource declares:
- what shape it expects
- what configuration it depends on
- what it returns
- what part is business logic versus contract definition
It also makes that interface extractable. Because the contract is declared structurally instead of being implied by arbitrary JavaScript, tooling can:
- extract the resource interface automatically
- summarize it for documentation or AI context
- index it for search and discovery
- use it programmatically in higher-level libraries or registries
That matters if you want resources to behave more like reusable components than opaque scripts.
When to use it
Use doc when any of these are true:
- the resource expects structured JSON input
- the resource depends on specific
dataproperties - the resource returns a stable envelope or result shape
- the resource will be reused across multiple steps or apps
- the resource acts as an internal API boundary
- you want AI tools to understand the resource without reverse-engineering it
Use it less aggressively when the resource is a tiny one-off script with no meaningful contract.
How to use it
Start from the contract, then write the implementation:
- Add a short
description(...)that explains the resource's role. - Add
inSchema(...)for the body or passed-message shape the resource actually consumes. - Add
dataSchema(...)for anycontext.getData(...)dependencies. - Add
outSchema(...)if the resource returns a stable result shape. - Use
asUserErrors()when invalid input should be treated as caller error rather than system failure. - Put the implementation in
run(...).
Preferred shape:
doc
.description("Resolve a registered MCP tool and call its handler")
.asUserErrors()
.inSchema({
type: "object",
required: ["jsonrpc", "id", "method"],
properties: {
jsonrpc: { type: "string" },
id: {},
method: { type: "string" },
params: {
type: "object",
properties: {
name: { type: "string" },
arguments: { type: "object" }
}
}
}
})
.dataSchema({
type: "object",
properties: {
registryStep: { type: "string" }
}
})
.outSchema({
type: "object",
required: ["ok"],
properties: {
ok: { type: "boolean" },
result: { type: "object" },
error: { type: "object" }
}
})
.run(() => {
// business logic here
});
Choosing the right schema
Document the shape the resource itself consumes, not the outer transport wrapper from an earlier layer.
For example:
- if an HTTP trigger receives a full HTTP event wrapper, the transport-normalizing resource should document that wrapper
- if a later processor receives only the normalized JSON body, that later processor should document the normalized body instead
This keeps the schema aligned with the resource's actual responsibility.
inSchema vs dataSchema
Use inSchema(...) for runtime payload input such as:
context.getBody()context.getPassedMessageAsString()
Use dataSchema(...) for configuration input such as:
context.getData("stepId")context.getData("fileId")context.getData("authorizationHeaders")
This distinction matters for both comprehension and reuse. It tells the reader what varies per call versus what is configured around the processor.
outSchema guidance
Add outSchema(...) when the resource acts like a boundary with a stable return envelope.
Good examples:
- RPC-style handlers
- registry lookups
- HTTP or lambda bridge responses
- resource or tool listing endpoints
It is less important for tiny mutators that simply rewrite context.setBody(...) for the next local step.
asUserErrors() guidance
Use asUserErrors() when invalid inputs should clearly be treated as caller mistakes.
Typical cases:
- required request fields are missing
- a field has the wrong type
- an enum value is invalid
Do not rely on handwritten JavaScript checks for basic schema rules if JSON Schema can express them directly.
This distinction matters when the resource is called by another step with sendToStep(...).
If the resource uses asUserErrors(), validation failures are exposed to the caller as user errors. The returned step context will surface them through:
response.getUserErrors()
If the resource does not use asUserErrors(), the same kind of failure is treated as a system-side failure instead, and the caller will find it under:
response.getSystemErrors()
In both cases, response.isErrored() will still be true. The difference is how the caller should interpret the failure:
getUserErrors(): the caller supplied bad input and may want to return a validation-style responsegetSystemErrors(): something went wrong in processing and may need operator attention or a generic internal-error response
Example caller pattern:
var response = context.sendToStep("validateInput", context.getBody());
if (response.isErrored()) {
if (response.getUserErrors().length > 0) {
context.addUserError(response.getFirstError(response.getUserErrors()));
return;
}
context.addSystemError(response.getFirstError(response.getSystemErrors()));
return;
}
Use asUserErrors() when you want schema validation failures to propagate through that first branch rather than being lumped into system failures.
Recommended examples in this repo
- Transport normalization: apigw-v2-http-bridge.js
- HTTP request normalization: forward-http-request.js
- Internal API boundary: tools-call.js
- Stateful internal service: registry-common.js
- Simple body-plus-data transform: property-replace.js
- Data-only contract: authorization.js
Common mistakes
- documenting the outer trigger wrapper instead of the normalized payload
- using
context.getData(...)without adataSchema(...) - returning a stable envelope but omitting
outSchema(...) - writing long implementation details in
description(...)instead of the resource's role - hand-validating basic required fields and types instead of declaring them in schema
Rule of thumb
If another person, another workflow, or an AI agent should be able to use the resource correctly without reading all of its JavaScript, it should probably have a meaningful doc contract.