Build modules
Write a module's form, dropdowns and action with the Stacksync CDK: requests, responses, field types, connections, credentials and errors.
A module is a folder modules/<module>/v<N>/ with three handler functions. Each file defines one function with the same name:
| File | Function | Stacksync calls it when |
|---|---|---|
schema.py | schema(request: Request) -> SchemaResponse | The step's form opens, and again when a field marked to reload changes. |
content.py | content(request: Request) -> ContentResponse | A dropdown needs its options. |
execute.py | execute(request: Request) -> ExecuteResponse | The workflow runs the step. |
config.py holds the module settings and context.md the context. Add a module with stacksync workflow-module create "<name>".
One module per operation
Build one module per operation and let the user choose the object in a dropdown inside it. This is the rule Stacksync reviewers check first.
modules/
create_records/ the object is a dropdown: Contact, Company, Deal, custom objects
update_records/
upsert_records/
delete_records/
get_records/
list_records/
send_email/ an action that is not record work gets its own module- Never build
create_contact,create_company,create_deal. - The object dropdown lists every standard object and the account's custom objects. Fetch them from the API's metadata endpoint when it has one.
- Choosing the object rebuilds the form with that object's fields.
- Never special-case one object with its own code path, and never replace real fields with a free-text JSON body field.
- Put code that several modules use, such as the API client, in
utils/and import it withfrom utils.api_client import ....
How the form works
- The step opens. Stacksync calls
/schemaand shows the connection picker and the object dropdown. - The user picks a connection. Stacksync calls
/contentfor the dropdown options. - The user picks an object. The field has
on_action: {"load_schema": true}, so Stacksync calls/schemaagain with the choice inform_data. - The form shows that object's fields. When the workflow runs, Stacksync calls
/executewith the values and the connection.
schema()
Return the full form each time. It replaces the current one.
from stacksync_cdk import Request, SchemaResponse
BASE_SCHEMA = {
"metadata": {"workflows_module_schema_version": "1.0.0"},
"fields": [
{
"id": "select_object",
"type": "string",
"label": "Select object",
"validation": {"required": True},
"ui_options": {"ui_widget": "SelectWidget"},
"content": {"content_objects": [{"id": "objects"}]},
"on_action": {"load_schema": True},
}
],
}
def schema(request: Request) -> SchemaResponse:
selected_object = request.form_data.get("select_object")
if not selected_object:
return SchemaResponse.from_schema(BASE_SCHEMA)
item_fields = fields_for(selected_object, request.credentials)
return SchemaResponse.from_schema(BASE_SCHEMA).add_record_array(selected_object, item_fields)SchemaResponse.from_schema(dict)takes a whole form.SchemaResponse(fields, connections=None, ui_options=None, metadata=None)builds one from a list of fields.add_record_array(object_name, item_fields, min_items=1, label=None)adds an array field"<object_name>s"whose rows holditem_fields, so one run can handle several records. Forcontact,execute()receivescontacts.- Every field needs
idandtype. - Don't add a connection picker. The CDK adds it from
connection_app_type. - Never raise because the connection is missing. Return the base form.
- When the fields come from your API, set
requires_credentials_for_schema=Trueinconfig.py. - Keep
schema()deterministic: the sameform_dataand connection give the same form.
Field types
Use "type": "string" for almost every field, including numbers, dates and dropdowns. Users can then type a value or a reference to an earlier step, such as {{ trigger_1.data.amount }}. Convert the strings to real types in execute(). Only booleans, arrays and objects keep their own type.
| Input | Field |
|---|---|
| Text | {"id": "name", "type": "string", "label": "Name", "validation": {"required": True}} |
| Number, amount | {"id": "amount", "type": "string", "label": "Amount"} |
| Date | {"id": "due_date", "type": "string", "label": "Due date", "description": "Format: YYYY-MM-DD"} |
| Checkbox | {"id": "active", "type": "boolean", "label": "Active", "default": False, "disable_replacement": True} |
| Fixed dropdown | {"id": "priority", "type": "string", "label": "Priority", "ui_options": {"ui_widget": "SelectWidget"}, "choices": {"values": [{"value": "low", "label": "Low"}, {"value": "high", "label": "High"}]}, "validation": {"pattern": ".*"}} |
| Dropdown from your API | {"id": "owner", "type": "string", "label": "Owner", "ui_options": {"ui_widget": "SelectWidget"}, "content": {"content_objects": [{"id": "owners"}]}, "validation": {"pattern": ".*"}} |
| Several values | {"id": "tags", "type": "string", "label": "Tags", "description": "Comma-separated."} |
| JSON | {"id": "config", "type": "string", "label": "Config", "ui_options": {"ui_widget": "CodeblockWidget", "ui_options": {"language": "json"}}, "validation": {"pattern": ".*"}} |
| List of rows | {"id": "line_items", "type": "array", "label": "Line items", "default": [{}], "validation": {"required": True, "min_items": 1}, "items": {"type": "object", "default": {}, "fields": item_fields, "ui_options": {"ui_order": [f["id"] for f in item_fields]}}} |
| Group | {"id": "address", "type": "object", "label": "Address", "default": {}, "fields": sub_fields, "ui_options": {"ui_order": [f["id"] for f in sub_fields]}} |
Rules behind the table:
- Dropdowns use
"validation": {"pattern": ".*"}, neverenum. Anenumrejects a value typed from an earlier step. Add"required": Truenext to the pattern when needed. - An option is always
{"value": ..., "label": ...}. - Booleans need
"disable_replacement": Trueto show as a checkbox. - Arrays and objects need
ui_options.ui_order. - Leave out
validationfor optional fields. - For update modules, make every field optional and show booleans as a dropdown with empty,
trueandfalse, so an untouched field never overwrites data. For upsert modules, add an optionalidfield that matches existing records.
Reloading on change
| Field setting | Effect |
|---|---|
"on_action": {"load_schema": True} | Changing the field calls /schema again. Use it on the object dropdown and on any field that changes which fields appear. |
"on_action": {"load_content": True} with "reload_content_objects": [{"id": "tables"}] | Changing the field reloads the listed dropdowns. The reloaded dropdown reads this field from request.form_data. |
A field can have both. Example of a dropdown that depends on another:
{"id": "schema_name", "type": "string", "ui_options": {"ui_widget": "SelectWidget"},
"content": {"content_objects": [{"id": "schemas"}]},
"on_action": {"load_content": True}, "reload_content_objects": [{"id": "tables"}],
"validation": {"pattern": ".*"}}content()
from stacksync_cdk import ContentResponse, Request
OBJECTS = [{"value": "contact", "label": "Contact"}, {"value": "company", "label": "Company"}]
def content(request: Request) -> ContentResponse:
if not request.credentials:
return ContentResponse()
content_objects = {}
for name in request.content_object_names:
if name == "objects":
content_objects["objects"] = OBJECTS + list_custom_objects(request.credentials)
elif name == "owners":
content_objects["owners"] = list_owners(request.credentials)
return ContentResponse(content_objects)request.content_object_nameslists the dropdown ids to fill in this call. Fill only those.- Return
ContentResponse({id: [{"value": ..., "label": ...}]}).previewis optional. The CDK adds the rest of the answer. valueis whatexecute()receives. Keep it a stable id, not a display name.- For long lists, return one page and mark that more exist:
ContentResponse().add("owners", options, has_next_page=True). - Never raise. Without a connection, or when your API fails, return no options for that dropdown so the form still works. Set a timeout on every call.
execute()
from stacksync_cdk import ExecuteResponse, ManagedError, Request
def execute(request: Request) -> ExecuteResponse:
selected_object = request.data.get("select_object")
if not selected_object:
raise ManagedError.validation_error("select_object is required.")
records = request.data.get(f"{selected_object}s") or []
if not records:
raise ManagedError.validation_error(f"At least one {selected_object} is required.")
results = [create_one(selected_object, to_api(record), request.credentials) for record in records]
successful = sum(1 for result in results if result["status"] == "success")
return ExecuteResponse(
{"total": len(results), "successful": successful, "failed": len(results) - successful, "results": results},
metadata={"affected_records": successful},
)request.dataholds the step's values, with references to earlier steps already resolved.- Validate required inputs first, with clear messages.
- Convert strings back to real types (numbers,
"true"and"false", comma-separated lists) and drop empty values before calling your API. - For several records, report each record's result and keep going when one fails.
ExecuteResponse(data, metadata=None, data_type=None). Later steps readdataas{{ MODULE.<step id>.output }}, so keep its shape predictable. Returning a plain value also works: it becomesdata.- Set a timeout on every call. Retry reads, and a
429after itsRetry-After. Never retry a create, update or delete after a server error, because the first call may have succeeded. - Keep no state between calls: no module-level caches, no global variables that change, no files on disk. The connector runs as several processes.
- Do nothing at import time: no network calls and no reading of the environment.
What Stacksync sends
Each handler gets a Request:
| Property | Contents |
|---|---|
request.data | /execute: the step's input values. /schema and /content: the request's data object. |
request.form_data | /schema and /content: the values entered so far. Empty when the form first opens. |
request.content_object_names | /content: the ids of the dropdowns to fill, such as ["objects"]. |
request.credentials | The selected connection. Empty until the user picks one. |
request.json | The whole request body. |
The bodies Stacksync sends:
// POST /<module>/v<N>/schema
{ "data": { "form_data": { "select_object": "contact" } }, "credentials": { … } }
// POST /<module>/v<N>/content
{ "data": { "form_data": { … }, "content_object_names": [ { "id": "objects" } ] }, "credentials": { … } }
// POST /<module>/v<N>/execute
{ "data": { "select_object": "contact", "contacts": [ { "name": "Ada" } ] },
"credentials": { … },
"workspace_id": "…", "workflow_id": "…", "workflow_module_instance_id": "…" }The answers the CDK builds from your return values:
// schema
{ "data": { "schema": { "metadata": { … }, "fields": [ … ], "connections": { … } } } }
// content
{ "data": { "content_objects": [ { "id": "objects", "content_object_name": "objects",
"data": [ { "value": "contact", "label": "Contact", "index": 0 } ],
"pagination": { "data": {}, "has_next_page": false } } ] } }
// execute
{ "data": { … }, "metadata": { "affected_records": 1 } }Connections and credentials
connection_app_type in stacksync.yml sets the connection users choose for every module. The CDK adds the connection picker to each form, and Stacksync passes the chosen connection with each call. Users create connections in Stacksync under Connections. Credentials never appear in your code or settings.
connection_app_type | Use it when | What request.credentials holds |
|---|---|---|
generic_api_credentials | Default. Works for any system. The user pastes one value. | One key: api_credentials, a string. It can be an API key, a token, a connection string or JSON that you parse. |
Your connector's own app_type | Stacksync has set up a dedicated connection type for your app, with named fields or OAuth. Ask Stacksync support. | That type's own fields. |
stacksync_user_api_key | The modules call the Stacksync API as the user. | api_key: the user's Stacksync API key. The connection is private to the user who created it. |
stacksync_workspace_api_key | The modules call the Stacksync API for the workspace. | api_key: a workspace API key. |
No other value is accepted.
Reading credentials:
token = request.credentials.get("api_credentials") # generic_api_credentials
api_key = request.credentials.api_key # api_key or api_key_bearer
access_token = request.credentials.access_token # OAuth: already refreshed by Stacksync
dsn = request.credentials.connection_string # database connections
instance_url = request.credentials.get("instance_url") # any other field by nameif request.credentials:tells you whether a connection is selected.request.credentials.valueis the whole credential object..token_type,.expires_at,.connection_app_typeand.connection_idare also available.- Read exactly the keys of your connection type. A key that is always
Nonemeans the code reads a name that the type doesn't have: withgeneric_api_credentials, everything is inapi_credentials. - When a key is missing, raise an error that shows the expected shape with a fake value, so the user knows what to fix:
if not request.credentials.get("api_credentials"):
raise ManagedError.validation_error(
"This connection has no API key. Create a generic_api_credentials connection whose value looks like this:\n"
'{"api_credentials": "your-api-key"}'
)- Never log credentials, return them, or put them in an error message.
- Describe the connection and the exact keys you read in the
## Connectionsection of everycontext.md.
Errors
Raise ManagedError for every failure the user can act on. Its message is shown in the step's result in Stacksync.
| Raise | Status | Example |
|---|---|---|
ManagedError.validation_error(message, data=None, metadata=None) | 400 | A required input is missing or has the wrong format. |
ManagedError.unauthorized(message) | 401 | The API rejected the credentials. |
ManagedError.forbidden(message) | 403 | The credentials lack a permission. |
ManagedError.not_found(resource, identifier) | 404 | The record doesn't exist. Message: "<resource> not found: <identifier>". |
ManagedError.server_error(message) | 500 | The API failed. |
ManagedError(message, data={}, metadata={}, status_code=400) | Any | Any other case. Use data to return partial results. |
The answer is {"error": "<message>"} with the status, plus data and metadata when you pass them. For example, a missing input answers 400 with {"error": "select_object is required."}. Any other exception becomes a 500 with the exception's text, so don't wrap whole handlers in try/except only to hide errors, and never report success when the API failed.
Next steps
Workflow Connector Development Kit
The Stacksync CDK: how a workflow connector project is laid out, its settings, module versions, CDK versions and the endpoints a connector serves.
Context and the /context endpoint
Write the context.md files that explain a workflow connector and its modules to Stacksync's AI, and read them back from the /context endpoints.