Skip to content

Extension contract

This page is copied from force-app/contracts/EXTENSIONS.md on every build. Edit that file, not this page.

Implementations of IModelProvider, ITool (with the optional IToolAuthoring), IContextProvider, and IToolAuthorizationPolicy run inside Core’s lifecycle but do not own its state. Core constructs and invokes each one through a with sharing boundary, so an inherited sharing extension sees the execution user’s record sharing. That grants no object or field access.

Same-org Apex in the empty namespace is trusted, and Core does not isolate it: it can call public helpers or use system-mode DML. Review extension classes and their dependencies before granting them execution.

  • Enforce sharing, CRUD, FLS, and business authorization for every business record you touch. Use with sharing or inherited sharing, WITH USER_MODE or AccessLevel.USER_MODE, and the ToolSecurity helpers.
  • Never query or change Pluto Core lifecycle records. Return a DTO; Core commits the outcome.
  • Never log credentials, authorization headers, plaintext trust-token values, complete prompts, or unbounded provider payloads.
  • Keep each returned string within 131,072 characters, all of them within 262,144 in total, and collections within the DTO limits. Core rejects oversized responses before trust scanning or persistence.
  • An operation key (runId:commandSequence:commandType[:toolName]) is unique per logical operation and stable across retries, recovery, and rescue, so it can serve as a downstream idempotency key. A Changes Outside Systems tool that asks for a retry must make the target deduplicate on it.
  • ToolRequest.principal and ContextRequest.principal carry the ExecutionPrincipal: initiating user, executionIdentityId, run user, and anchorRecordId. Tools and context providers run as the run user: the identity’s execution user when executionIdentityId is set, the starting user when it is null.
  • When principal.attendedByUserId is set, a person started a run of an agent with an identity, and the run must not exceed that person’s access:
    • Core filters returned recordSets for them.
    • Code that reads or changes records any other way calls ToolSecurity.assertRequesterCanRead, assertRequesterCanEdit, or assertRequesterCanCreate (no-ops on unattended runs).
    • Core withholds a tool’s text output and error messages unless its class implements IRequesterFilteredTool. Implement it only if the class filters record data in that text with ToolSecurity.requesterReadable, or writes none there (for example, only IDs it just created). Data from an outside system qualifies only when anyone may see it, because the system answers for its own credential, not for the person.
  • Use the Named Credential and model name in ModelRequest. Core reads them from the AgentModel__mdt record whose Model Adapter Class names your class. Never accept secrets in metadata or the request.
  • Honor ModelRequest.timeoutSeconds for the single callout.
  • Cap the provider’s output at ModelRequest.maxOutputTokens when it is set. When it is null, choose a cap that finishes within the timeout.
  • Only ModelRequest.instructions and the tool definitions are instructions. Send every contexts item and every transcript entry with untrusted = true in user or tool roles, delimited from the instructions, never in the system role.
  • Pass protected-value tokens such as {{EMAIL_3_9f2c61ab}} through unchanged.
  • Send each tool’s inputSchema as written, including the _rationale property Core adds to every tool and replayed call. Core records it as the call’s reason.
  • When ModelRequest.allowMultipleToolCalls is false, request at most one tool call (for example OpenAI’s parallel_tool_calls: false). Core answers a batch with TOOL_BATCH_NOT_ALLOWED and asks again. null allows several.
  • Return exactly one of a final, schema-valid output, or one through eight active ModelToolCall values with bounded JSON input.
  • Throw AgentKernelException.RetryableException only for an explicitly transient condition; Core also retries System.CalloutException. Throw PermanentException for configuration, programming, authentication, schema, or deterministic request faults. Any other exception is permanent.

ModelRequest.transcript holds every turn, oldest first; there is no separate input field. Map each entry by type:

TypeSourceRole
OriginalInputThe run’s input; always first. A run started from a record sends {"record":{"object","id","name"},"input":…}User
ToolCall, ToolResultA call and its result, sharing a toolCallId (Core synthesizes one if the provider gave none)Assistant, tool
UserInputA human response to a wait, including a conversational agent’s next messageUser
AssistantOutputThe agent’s earlier answer in a conversational runAssistant
OperatorGuidanceRescue guidance: only the operator’s words and Core’s error codeSystem or developer
ModelFeedbackWhy Core rejected the previous response (invalid output or tool call)System or developer

A failed tool result carries the tool’s errorCode and a bounded, tokenized errorMessage, or one of: TOOL_APPROVAL_DENIED with the reviewer’s tokenized comment; TOOL_APPROVAL_EXPIRED when nobody decided in time; TOOL_AUTHORIZATION_DENIED for a policy denial; TOOL_NOT_RUN when Core never ran the call, such as the rest of a batch after one call failed or waited; or the operator’s error code and message when an operator confirms an Unknown call failed.

Register the class with an Agent Tool Type (AgentToolType__mdt) record deployed with it. Admins pick it on AgentTool__c.ToolType__c and never see the class name.

  • Set Tool Class to the class and keep Implementation Type at Apex. Standard and Flow mark Core’s own types, listed in Tool types.
  • Set Effects. It is required:
    • Stays in This Org when the tool only reads or changes this org’s records.
    • Reads Outside Systems when it calls outside systems but only reads, so running a call twice has the same effect as once, such as a GET lookup.
    • Changes Outside Systems for anything else Core cannot roll back: a callout that may create, change, send, or delete, an email, or async work. When unsure, choose this one.
  • Check Requires Object when the configuration names one object in objectApiName.

Core checks that the tool type exists and its class implements ITool at validation and before invocation. That grants no data access.

validateConfiguration gets the Implementation Configuration and the input schema the tool has (hand-written, or built with the admin’s edits) when an active tool is saved and before each run starts. It runs as the saving admin and must stay query-only; DML, callouts, or async work fail the save. Check what you can without side effects, such as object, field, and flow names, option values, and existence; leave the run user’s access to execute. Return one admin-readable message per problem, or an empty list.

Settings and inputs (optional):

Implement IToolAuthoring so an authoring screen shows each setting as a control instead of a JSON box. When the tool’s inputs follow from its settings, describe also builds them, so the admin fills in only the settings and the tool has a blank Input Schema. Core calls describe on each run, so a picklist, field, or Flow change reaches the model without saving the tool. describe gets a ToolConfiguration with the Implementation Configuration and returns a ToolAuthoring:

  • settings: each Implementation Configuration key the admin fills in, with a label, type, whether it is required, help text, and allowed values when there is a fixed set. blankMeaning says what a blank optional setting does, such as “The run’s record”. List settings in the order the admin decides them. The type picks the control an authoring screen shows:

    TypeValue
    string, text, integer, number, booleanA plain value. text is several lines.
    textListA list of text values, one per line.
    keyValuesAn object of names to text values. allowedValues limits the values.
    objectAn object API name.
    recordTypeA record type developer name of the object named by objectSetting.
    fieldsA list of field API names of the object named by objectSetting or objectApiName.
    fieldValuesAn object of field API name to value, for the object named by objectSetting.
    recordA record ID of one of recordObjects, such as User and CollaborationGroup.
    queryA SOQL query.
    flowAn autolaunched Flow API name.
    reportA report API name.
    queueA queue API name.
    namedCredentialA Named Credential API name.
    jsonAny JSON value. Screens show unknown types this way.
  • inputSchema: the closed input schema the settings give the model.

  • problem: when the settings cannot build a schema yet, what the admin must fix. Return inputSchema or problem.

  • inputsWrittenByAdmin: set it to true when only the admin knows the inputs, as for an API call. Core then ignores inputSchema and problem, and the admin writes the inputs in Input Schema. Check settings problems in validateConfiguration instead.

describe runs on each run, save, and preview, as whoever is running, inside the same query-only boundary as validateConfiguration. Keep it cheap: Core calls it once per tool per transaction. Do not throw; Core turns a thrown exception into problem. When a run cannot build a tool’s schema, the run pauses with TOOL_SCHEMA_UNAVAILABLE and the problem, and an operator retries it after the fix. Core’s record, query, Chatter, and Flow tools implement it.

The admin narrows the built inputs with edits that Core stores and applies, so a class never handles them. They sit in Implementation Configuration under inputOverrides, by input path:

"inputOverrides": {
"recordData.Subject": { "description": "One line naming the product." },
"recordData.Description": { "required": true },
"recordData.Priority": { "allowedValues": ["Medium", "High"] },
"decision": { "allowedValues": ["released", "hold"] },
"reason": { "maxLength": 255 }
}

An edit can replace a description, make an optional input required, keep some of the values an input offers, limit free text to a list of values, or lower a text input’s length limit. Save refuses an edit that names an input the tool does not build or that would widen it. On a run, a kept value the tool no longer offers is dropped. Do not declare a setting named inputOverrides.

A tool whose class does not implement IToolAuthoring, or sets inputsWrittenByAdmin, needs a hand-written Input Schema, and validateConfiguration gets it in inputSchema. A tool that takes no inputs has {"type":"object","properties":{},"additionalProperties":false}.

Input and output:

  • Every executable input schema, including nested objects, must set additionalProperties: false.
  • Do not declare a top-level input named _rationale. Core adds it to every tool and strips it before validation.
  • Return a bounded ToolResponse with output or recordSets, not both. Structured output allows at most 10 named sets and 200 records, before Core’s stricter serialization and trust checks.
  • Return Salesforce records as recordSets, never serialized into output. Core tokenizes classified fields only in recordSets.

Errors and outcomes:

  • Write PermanentException messages and errorMessage for the model. Core returns the code and up to 2,000 characters of the message so the model can correct the call, and tokenizes detected sensitive values. Never include secrets.
  • Use PermanentException only for a confirmed permanent failure.
  • Throw RetryableException, or return retryable = true, only when a repeat is safe: nothing happened, or the target deduplicates on the operation key. Core trusts that claim for every tool type.
  • Use OutcomeUnknownException, or outcomeUnknown = true, only when a real side effect may have happened and the target cannot prove the result.
  • To wait for another system’s answer, return awaitingExternalInput = true with a waitingReason and a resumeJsonSchema. Give that system ToolRequest.resumeKey; its callback passes the key and the answer to the Resume Agent Wait Flow action or AgentRunService.resumeWait. The key resumes only this call’s wait. Treat it as a secret: never log it or put it in output.

Effects is the complete recovery contract:

  • Stays in This Org: no callouts, emails, immediate platform events, or async work. Core runs the tool in a savepoint and rolls back its DML on any outcome but success or an intentional wait. The call is returned to the model or retried, never Unknown, and a lost worker is safe to repeat. A callout fails at the savepoint and pauses the run; an email, immediate event, or enqueued job makes the outcome Unknown. The call may run in the same transaction as the model turn or Reads Outside Systems call before it, after other calls from that turn, so the tool must not rely on a fresh transaction or on governor limits no other call has used.
  • Reads Outside Systems: the tool runs outside a savepoint, but Core handles failures as it does for Stays in This Org: a callout failure or lost worker repeats the call within the run’s retry limits, an unexpected exception returns to the model, and a malformed response pauses the run. A tool that throws OutcomeUnknownException is still Unknown.
  • Changes Outside Systems, or a value Core does not recognize: an unexpected exception, a malformed or oversized response, or a lost worker makes the outcome Unknown. Core repeats the call only when the tool asks for a retry.

A Stays in This Org tool must not start work that a rollback cannot undo. Core detects some of it and makes the outcome Unknown; the rest it cannot see, so such work would repeat when the call is retried. Choose Changes Outside Systems for any tool that does any of these:

Work the tool startsCore detects it
CalloutYes: it fails at the savepoint and pauses the run
Email (Messaging.sendEmail)Yes: Unknown
Platform event that publishes immediatelyYes: Unknown
System.enqueueJobYes: Unknown
@future methodYes: Unknown
Database.executeBatchNo
System.schedule or System.scheduleBatchNo

DML, including platform events that publish after commit, is rolled back with the savepoint, so it is safe.

At a command’s first lease, Core records the tool type’s Effects and a hash of the binding (tool type, class, Effects, configuration). Recovery, retry, and cancellation use these even if the records change later. If the active tool no longer matches, Core pauses the command before its first invocation, or records Unknown for reconciliation if an earlier attempt may have had a side effect. Restore the original binding or start a new operation; never edit a leased command.

  • Query-only. DML, callouts, email, future work, and Queueable enqueue are prohibited and rolled back.
  • Providers run in configured order under per-extension query, row, CPU, and heap budgets. Core skips providers once its lifecycle reserve is reached. A failed, invalid, or over-budget provider is omitted and recorded as SkippedBudget, SkippedGovernorBudget, or Failed.
  • Return a unique source name and exactly one of bounded text content or bounded recordSets. Return Salesforce records as recordSets, never serialized into content: Core tokenizes their classified fields and filters them for an attended run’s person. Use content only for text that has no field behind it, such as file text or message history. Core scans it for emails, phone numbers, card numbers, US SSNs, and IBANs, but cannot see field classifications in it. Core omits text content from attended runs (WithheldForRequester).
  • Core enforces the aggregate context budget and caps each provider at 16,000 characters. Oversized record sets keep whole leading records of each set and are marked Truncated, so put the most needed records first. Text content is cut at the budget.

The built-in FieldSetContextProvider uses ImplementationType__c = Field Set. Set ObjectApiName__c, FieldSetName__c, and ContextSubject__c; leave the Apex reference and JSON configuration blank.

  • Anchor Record requires StartRequest.anchorRecordId; it never infers a record from input. Initiating User requires the User object.
  • Field sets support 1 through 30 direct fields.
  • The run user needs sharing access to the record and read access to the object and fields.

Policies follow the same query-only rule and governor reserve as context providers. Core evaluates the policy only for calls of tools whose Approval Mode is Decided by Policy, after it checks the call’s arguments against the tool’s Input Schema. Calls of Not Required and Always Required tools never reach it. Build the answer from the request:

public AgentKernelModels.ToolAuthorizationResponse evaluate(AgentKernelModels.ToolAuthorizationRequest request) {
if (request.toolType != 'NorthwindIssueRefund') {
return request.allow();
}
Decimal amount = request.decimalInput('amount');
if (amount == null) {
return request.deny('Send the refund amount.');
}
if (amount <= 25) {
return request.allow();
}
return request.requireApproval('The refund is over 25.').byQueue('Finance_Approvers');
}
  • Match on request.toolType, the API name of the tool’s Agent Tool Type. toolName is the tool’s API name on the agent, and an admin can change it. A policy that matches on toolName stops covering a tool when it is renamed.
  • request.inputValue(name) returns one top-level input. request.decimalInput(name) returns a number input, or null when it is missing or not a number.
  • request.deny(reason) always stops the call. The model gets TOOL_AUTHORIZATION_DENIED with your reason and continues, so write the reason for the model.
  • allow() runs the call, and requireApproval(reason) makes it wait.
  • requireApproval needs a nonblank reason. The approver sees it. Describe the call, such as “The refund is over 200”, not who should approve it.
  • requireApproval returns AgentKernelModels.ToolApprovalResponse. Name who approves this call with .byQueue(apiName) or .byUser(userId). Each replaces the other. The tool has no approver of its own, so a hold that names neither pauses the run with TOOL_AUTHORIZATION_POLICY_INVALID.
  • .withTimeoutMinutes(minutes) sets this call’s deadline in whole minutes, 1 through 10,080 (7 days). It replaces the tool’s Approval Timeout, so a large refund can get a shorter deadline than a small one. Without it, the tool’s Approval Timeout applies, or 24 hours when that is blank. The tool’s When Approval Expires always applies.
  • Core runs the policy again before an approved call runs. A deny still stops it. Any other answer, including a hold that names someone else, lets the approved call run, so a call is never approved twice.
  • Core pauses the run with TOOL_AUTHORIZATION_POLICY_INVALID for a blank reason, a timeout outside 1 through 10,080 minutes, or a byUser Id that is not a user.
  • Core pauses the run with APPROVAL_ASSIGNMENT_UNRESOLVED when the named queue does not exist or does not support Agent Approval records, or the named user cannot decide approvals. A missing queue never falls back to another approver.
  • The Run Waiting event records requiredBy: Tool Setting for an Always Required tool, or Policy.
  • Runs of agents without an identity cannot wait: requireApproval pauses them with INITIATING_USER_APPROVAL_UNSUPPORTED. Return allow() or deny() when request.executionIdentityId is null.

Core creates the Agent Approval and decides who may approve it; see Require approval.

Test against the exact Pluto Core source you will deploy. At minimum, cover sharing and FLS under a standard user, timeouts, permanent versus retryable classification, oversized responses, schema rejection, operation-key deduplication, and the Unknown path for an ambiguous external effect.

Pluto is licensed under the GNU Affero General Public License, version 3 only.