# PKL Contracts

Every Atlan app ships a machine-readable contract — `contract/app.pkl` — that drives the entire deployment lifecycle: the setup form users fill in, the credential form, the Automation Engine DAG, and the typed Python `Input` class your workflow receives.

One file, `app.pkl`, generates everything. This guide explains how to write and maintain it.

## How It Works

```
contract/app.pkl
    │
    ├── pkl eval → app/generated/{name}.json               (setup form)
    ├── pkl eval → app/generated/atlan-connectors-{name}.json  (credential form)
    ├── pkl eval → app/generated/manifest.json             (AE DAG)
    └── pkl eval → app/contracts/_input.py                 (typed Input class)
```

The SDK's handler auto-serves these files:

| Generated File | Served At |
|---|---|
| `{name}.json` | `GET /workflows/v1/configmap/{name}` |
| `atlan-connectors-{name}.json` | `GET /workflows/v1/configmap/atlan-connectors-{name}` |
| `manifest.json` | `GET /workflows/v1/manifest` |

No handler overrides needed. Just commit the generated files.

## Prerequisites

Install `pkl`:
```bash
brew install pkl
pkl --version   # 0.27+ recommended
```

## Project Structure

After setup, your app repo looks like this:

```
your-app/
├── contract/
│   ├── PklProject              # Package dependency declaration
│   ├── PklProject.deps.json    # Resolved lock file (generated)
│   ├── app.pkl                 # YOU write this
│   └── generated/              # DO NOT EDIT — generated by pkl eval
│       ├── {name}.json
│       ├── atlan-connectors-{name}.json
│       ├── manifest.json
│       └── _input.py
└── app/
    └── contracts/
        └── _input.py           # Moved here by poe generate
```

## Step 1 — Declare the Toolkit Dependency

Create `contract/PklProject`:

```
amends "pkl:Project"

dependencies {
  ["app-contract-toolkit"] {
    uri = "package://atlanhq.github.io/application-sdk/contracts/app-contract-toolkit@<LATEST_VERSION>"
  }
}
```

Resolve and lock it:
```bash
cd contract && pkl project resolve
# Creates PklProject.deps.json — commit this file
```

## Step 2 — Write `app.pkl`

A minimal contract:

```pkl
amends "package://atlanhq.github.io/application-sdk/contracts/app-contract-toolkit@<LATEST_VERSION>#/App.pkl"

import "package://atlanhq.github.io/application-sdk/contracts/app-contract-toolkit@<LATEST_VERSION>#/Config.pkl"
import "package://atlanhq.github.io/application-sdk/contracts/app-contract-toolkit@<LATEST_VERSION>#/Credential.pkl"

name = "my-connector"
displayName = "My Connector"
iconUrl = "https://cdn.atlan.com/assets/connectors/my-connector.png"
connectorType = "DATABASE"

credentials {
  ["basic"] = new Credential.FieldSpec {
    name = "basic"
    displayName = "Basic Authentication"
    fields {
      ["host"] = new Credential.Field {
        displayName = "Host"
        required = true
      }
      ["port"] = new Credential.Field {
        displayName = "Port"
        defaultValue = "5432"
      }
      ["username"] = new Credential.Field {
        displayName = "Username"
        required = true
      }
      ["password"] = new Credential.Field {
        displayName = "Password"
        required = true
        sensitive = true
      }
    }
  }
}

workflow {
  steps {
    ["connection"] = new Config.Step {
      title = "Connection"
      properties {
        ["credential-guid"] = new Config.CredentialInput {
          title = "Credentials"
          credType = "atlan-connectors-my-connector"
        }
        ["connection"] = new Config.ConnectionCreator {
          title = "Connection Name"
        }
      }
    }
    ["metadata"] = new Config.Step {
      title = "Metadata"
      properties {
        ["extraction-method"] = new Config.Radio {
          title = "Extraction Method"
          possibleValues { "direct" }
          default = "direct"
        }
        ["include-filter"] = new Config.SqlTree {
          title = "Include Metadata"
          sqlQuery = "show atlan schemas"
          credentialRef = "credential-guid"
        }
        ["preflight-check"] = new Config.SageV2 { title = "" }
      }
    }
  }
}
```

**Required fields:**

| Field | Type | Description |
|---|---|---|
| `name` | String | Kebab-case app identifier (must match `atlan.yaml` `app_id`) |
| `displayName` | String | Human-readable connector name |
| `credentials` | Mapping | Auth type → field definitions |
| `workflow.steps` | Mapping | Ordered form steps (first is always "connection") |

## Step 3 — Add the Generate Task

The SDK does not ship a built-in `generate` poe task — you add it to your connector's `pyproject.toml`. Add the following block under `[tool.poe.tasks]`:

```toml
[tool.poe.tasks.generate]
shell = """
set -euo pipefail
cd contract
pkl eval app.pkl --multiple-file-output ../app/generated/
mv ../app/generated/_input.py ../app/contracts/_input.py
"""
```

Then run:
```bash
uv run poe generate
```

## Step 4 — Exclude Generated Files from Linting

The generated `_input.py` uses patterns that trip pre-commit hooks. Add to:

**`.pre-commit-config.yaml`:**
```yaml
- id: debug-statements
  exclude: app/contracts/_input\.py
```

**`pyproject.toml`:**
```toml
[tool.ruff]
exclude = ["app/contracts/_input.py"]

[tool.pyright]
exclude = ["app/contracts/_input.py"]
```

## The DAG — How Extraction Flows

By default, `manifest.json` describes a two-node DAG:

```
extract → publish
```

The `extract` node runs your app's main workflow. The `publish` node calls the Atlan ingestion API with the output of `extract`.

### Referencing Extract Outputs in Publish

```pkl
// In app.pkl — add extraNodes to customize publish args
extraNodes {
  ["publish"] = new PublishNode {
    upstream = "extract"
  }
}
```

The manifest uses `$.extract.outputs.output_path` to pass the extract output to publish. Your workflow's `run()` return dict must include every key the DAG references:

```python
async def run(self, input: ExtractionInput) -> ExtractionOutput:
    output_path = await self.fetch_and_write_metadata(...)
    return ExtractionOutput(output_path=output_path)
```

### Adding an Intermediate Node

```pkl
extraNodes {
  ["lineage"] = new DAGNode {
    displayName = "Extract Lineage"
    workflowType = "LineageExtractionWorkflow"
    taskQueue = "atlan-my-connector-{deployment_name}"
    args {
      ["credential"] = "{{credential}}"
      ["connection"] = "{{connection}}"
    }
    dependsOn { "extract" }
  }
  ["publish"] = new PublishNode {
    upstream = "lineage"
  }
}
```

## Widget Reference

| Widget | When to Use |
|---|---|
| `Config.Radio` | Extraction method, toggle between modes |
| `Config.CredentialInput` | Credential GUID picker |
| `Config.ConnectionCreator` | New connection form |
| `Config.SqlTree` | Schema/table filter (direct SQL connectors) |
| `Config.TextInput` | Regex patterns, bucket names |
| `Config.BooleanInput` | Feature flags |
| `Config.DropDown` | Select from list (>4 options) |
| `Config.SageV2` | Pre-run validation checks |
| `Config.ConditionalInput` | Switch widget based on another field (e.g., sqltree in direct mode, text in agent mode) |
| `Config.AgentSelector` | Secure agent credential overrides |

The `SageV2` widget must always be the **last** property in the final step.

## Bumping the Toolkit Version

When a new toolkit version ships:

1. Update the version in `contract/PklProject`
2. Re-resolve: `cd contract && pkl project resolve`
3. Regenerate: `uv run poe generate` (the user-supplied task from Step 3)
4. Diff the generated files — review changes before committing

Check the current latest version:
```bash
gh release list --repo atlanhq/app-contract-toolkit --limit 5
```

Always use the latest toolkit — older versions have known bugs (pre-0.2.4 generates wrong widget names, pre-0.2.9 generates hyphenated Python field names in `_input.py`).

## Verification

Before deploying, verify the contract is correct:

```bash
# 1. Regenerate from scratch
uv run poe generate  # the user-supplied task from Step 3

# 2. Confirm all DAG refs are satisfied
python3 -c "
import json
m = json.load(open('app/generated/manifest.json'))
publish = m.get('dag', {}).get('publish', {})
for k, v in publish.get('inputs', {}).get('args', {}).items():
    if isinstance(v, str) and '$.extract.outputs.' in v:
        key = v.split('.')[-1]
        print(f'publish needs from extract: {key}')
"

# 3. Check toolkit version vs latest
CURRENT=$(grep -oE '@[0-9.]+' contract/PklProject | tr -d '@')
echo "Contract toolkit: $CURRENT"

# 4. Verify _input.py exclusions
grep "_input.py" .pre-commit-config.yaml pyproject.toml | head -5
```

## Examples

- Redshift (SQL connector): [app-contract-toolkit/examples/redshift](https://github.com/atlanhq/app-contract-toolkit/blob/main/examples/redshift/app.pkl)
- Teradata (ConditionalInput + AgentSelector): [app-contract-toolkit/examples/teradata](https://github.com/atlanhq/app-contract-toolkit/blob/main/examples/teradata/app.pkl)
- Toolkit README: [app-contract-toolkit/README.md](https://github.com/atlanhq/app-contract-toolkit/blob/main/README.md)
- Full field reference: [app-contract-toolkit/docs/reference.md](https://github.com/atlanhq/app-contract-toolkit/blob/main/docs/reference.md)

## See Also

- [Deployment Guide](deployment.md) — `atlan.yaml` and Kubernetes topology
- [Multi-App Coordination](multi-app-coordination.md) — complex DAGs across multiple apps
- `/contract` skill — interactive PKL contract creation and migration
