Alchemy resources
Every Maple resource the Alchemy provider manages: props, outputs and lifecycle for dashboards, alert rules, alert destinations, API keys and ingest keys.
The resources @maple-dev/alchemy manages. New here? Start with the Alchemy quick start.
| Resource | Lifecycle | Main output |
|---|---|---|
Maple.Dashboard | Create, update in place, delete | dashboardId |
Maple.AlertDestination | Create, update in place, delete. Changing type replaces it | destinationId |
Maple.AlertRule | Create, update in place, delete. Ownership-tagged per stack | ruleId |
Maple.ApiKey | Create, roll, revoke. Any other prop change replaces the key | secret |
Maple.IngestKeys | Read-only singleton | privateKey |
Props use the API’s snake_case wire shape, exactly as the interactive API reference documents the matching create endpoint. Outputs use camelCase.
Maple.Dashboard
Effect.gen(function* () {
const dash = yield* Maple.Dashboard("checkout", {
name: "Checkout",
description: "Golden signals for the checkout flow",
tags: ["checkout"],
time_range: { type: "relative", value: "12h" },
widgets: [/* widget documents */],
sections: [/* collapsible widget groups */],
variables: [/* dashboard variables */],
})
})
| Prop | Type | Notes |
|---|---|---|
name | string | Required |
description | string | null | |
tags | string[] | |
time_range | object | e.g. { type: "relative", value: "12h" } |
widgets | object[] | Passed through verbatim |
sections | object[] | Widgets join one with section_id and tab_id; their layout is relative to the section’s grid |
variables | object[] | Passed through verbatim |
Outputs: dashboardId (dash_…), name.
The easiest way to get a widget document is to build the dashboard in the UI and copy it from GET /v2/dashboards/{id}. See Build dashboards.
Maple.AlertDestination
A notification channel that alert rules deliver to. Props are discriminated on type:
type | Channel fields |
|---|---|
pagerduty | integration_key |
webhook | url, optional signing_secret |
discord | webhook_url |
telegram | bot_token, chat_id |
email | member_user_ids (Maple user IDs in your org) |
Every variant also takes name (required) and enabled (defaults to true on both create and update).
Effect.gen(function* () {
const hooks = yield* Maple.AlertDestination("incident-webhook", {
type: "webhook",
name: "Incident bot",
url: "https://hooks.example.com/maple",
signing_secret: Redacted.make(process.env.WEBHOOK_SECRET!),
})
})
- Channel secrets (
integration_key,signing_secret,webhook_url,bot_token) are write-only: the API never returns them. Pass them asRedactedvalues so they stay out of logs and plan output. Because they cannot be read back, the provider re-sends them on every update. - Changing
typereplaces the destination with a new ID. - Slack, other chat-connector and Hazel destinations depend on integrations linked in Maple, so manage them in Notification destinations, not here.
- Deleting a destination that rules still reference fails with a conflict. When the rules live in the same stack, Alchemy deletes them first.
Outputs: destinationId (dest_…), name, type, enabled.
Maple.AlertRule
Effect.gen(function* () {
yield* Maple.AlertRule("api-p95", {
name: "API p95 latency",
severity: "warning",
signal_type: "p95_latency",
comparator: "gt",
threshold: 800,
window_minutes: 10,
service_names: ["api"],
consecutive_breaches_required: 2,
destination_ids: [oncall.destinationId],
})
})
| Prop | Type | Notes |
|---|---|---|
name | string | Required. Unique per organization |
severity | "warning" | "critical" | Required |
signal_type | AlertSignalType | Required. See values below |
comparator | AlertComparator | Required. See values below |
threshold | number | Required. Error rates are 0-1 ratios |
threshold_upper | number | null | Upper bound for between / not_between |
window_minutes | number | Required |
destination_ids | string[] | Required. dest_… IDs, usually destination.destinationId outputs |
enabled | boolean | |
notes | string | null | |
service_names | string[] | Only these services |
exclude_service_names | string[] | All services except these |
group_by | string[] | null | |
tags | string[] | Up to 19. alchemy: tags are reserved (see below) |
minimum_sample_count | number | |
consecutive_breaches_required | number | Breaching windows before it fires |
consecutive_healthy_required | number | Healthy windows before it resolves |
renotify_interval_minutes | number | |
alert_on_no_data | boolean | Count a window with no data as a breach |
apdex_threshold_ms | number | null | For apdex rules |
query_builder_draft | object | null | For builder_query rules, passed through verbatim |
raw_query_sql | string | null | For raw_query rules |
raw_query_reducer | string | null | identity, sum, avg, min or max |
notification_template | object | null |
signal_type:error_rate,p95_latency,p99_latency,apdex,throughput,builder_query,raw_querycomparator:gt,gte,lt,lte,eq,neq,between,not_between
Signal-specific fields are validated by the API against signal_type, so a mismatch fails the deploy with a 400. See Alert rules for what each signal measures.
Outputs: ruleId (alrt_…), name, enabled.
Ownership
Each rule carries one alchemy:<hash> tag that identifies the stack, stage and logical resource that owns it. It uses one of Maple’s 20 tag slots, which is why tags is capped at 19. The tag is what makes state recovery safe:
- If Alchemy state is lost, the provider finds the rule by name and takes it back only when the ownership tag matches.
- A rule with the same name and no matching tag (created in the UI, or by another stack or stage) fails the deploy instead of being overwritten. To take it over deliberately, wrap the resource in Alchemy’s
adopt(true)or deploy with--adopt. - A stack never deletes a rule another stack has adopted.
Keep the ownership tag intact, and use distinct rule names for different stages that share one Maple organization.
Maple.ApiKey
Effect.gen(function* () {
const ci = yield* Maple.ApiKey("ci", {
name: "ci-pipeline",
scopes: ["dashboards:write", "alerts:read"],
rotate: 1,
})
// ci.secret is a Redacted<string>; bind it into another resource's env.
})
| Prop | Type | Notes |
|---|---|---|
name | string | Required |
description | string | |
scopes | string[] | Omit for full access |
kind | "standard" | "mcp" | Defaults to standard. mcp keys only work on the MCP server |
expires_in_seconds | number | |
rotate | number | string | Bump it (1 to 2) to roll the key |
The API has no update endpoint for keys:
- Changing
name,description,scopes,kindorexpires_in_secondsreplaces the key: a new ID and a new secret, and the old key is revoked. - Bumping
rotaterolls the key in place: same ID, name and scopes, new secret. - Deleting the resource revokes the key.
Outputs: keyId (key_…), name, keyPrefix, secret (Redacted, the full maple_ak_… value).
The API returns a secret only once, at create or roll time. The provider stores it in Alchemy state, which is the only place it survives, so keep your state somewhere durable and encrypted.
Maple.IngestKeys
Effect.gen(function* () {
const ingest = yield* Maple.IngestKeys("ingest")
// ingest.publicKey -> maple_pk_… for browser and mobile senders
// ingest.privateKey -> maple_sk_… for server-side senders
})
A read-only view of your organization’s ingest keys. It takes no props, and there is one per organization. Deleting the resource only stops tracking it; the keys stay valid. Rolling is not modeled, because a roll immediately invalidates the old key for every sender. Roll ingest keys from Settings → Ingestion instead.
Outputs: publicKey, privateKey (both Redacted), publicRotatedAt, privateRotatedAt.
Drift and sync
- A deploy compares declared props against what the API returns and patches only when they differ. Fields you leave out of props stay unmanaged, so edits to them in the UI are kept.
alchemy syncrestores declared dashboard fields and alert rule configuration to what the stack declares.- Alert destination secrets cannot be compared, so drift in them is only caught when you change the prop.
Errors and retries
The client retries 429 and 5xx responses with bounded exponential backoff and honors retry_after_seconds from the error envelope. The API allows 600 requests per 60 seconds per key, which a large stack stays well under.
Failures surface as typed errors in Effect’s error channel. An API failure is tagged with the server’s own @maple/http/… error tag and carries the HTTP status and the error body (type, code, message); Maple.MapleErrorTags has the tags to catchTag on. Client-side failures are MapleApiTransportError, MapleApiResponseDecodeError, MapleApiProtocolError and MapleApiRequestEncodingError. Rule ownership conflicts are MapleAlertRuleOwnershipError, and invalid or reserved rule tags are MapleAlertRuleTagsError.