Skip to main content
Version: Next

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 for
  • inSchema(...): the expected input body shape
  • dataSchema(...): the expected processor data shape
  • outSchema(...): the output body shape
  • asUserErrors(): whether schema failures should be treated as user-facing input errors
  • run(...): 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 data properties
  • 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:

  1. Add a short description(...) that explains the resource's role.
  2. Add inSchema(...) for the body or passed-message shape the resource actually consumes.
  3. Add dataSchema(...) for any context.getData(...) dependencies.
  4. Add outSchema(...) if the resource returns a stable result shape.
  5. Use asUserErrors() when invalid input should be treated as caller error rather than system failure.
  6. 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 response
  • getSystemErrors(): 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.

Common mistakes

  • documenting the outer trigger wrapper instead of the normalized payload
  • using context.getData(...) without a dataSchema(...)
  • 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.