Skip to main content
3Nsofts logo3Nsofts
Foundation ModelsUpdated · June 2026

Tool Calling in Foundation Models: Wiring Real Tools (HealthKit, MapKit) Not Toy Examples

Author
Ehsan Azish · 3NSOFTS
Updated
June 2026
Read time
15 min read
Level
Intermediate → Senior
Platform
iOS 26+, Foundation Models, async/await

Implementation Notes

  • ~/ What broke: Toy tool examples ignore permissions, failure, and real data.
  • ~/ What to do: Wrap real tools with permission gates, typed errors, and observable audit trails.
Foundation Models tool callingTool protocol Foundation Modelson-device AI HealthKitFoundation Models MapKitLanguageModelSession tools

Tool calling lets the on-device model invoke your code mid-generation — to fetch live data it can't know, like the user's health metrics or nearby places — and weave the result into its answer. Every tutorial demos this with a fake weather tool returning a hardcoded string. This guide wires real system frameworks (HealthKit, MapKit) and handles the things toy examples skip: permissions, failures, and keeping the model from hallucinating around your data.

The Tool protocol

A tool is a type the model can call. It declares its name, a description the model uses to decide when to call it, an arguments type (@Generable, so the model can fill it), and a call implementation that runs your code.

import FoundationModels

struct StepCountTool: Tool {
    let name = "getStepCount"
    let description = "Returns the user's step count for a given day."

    @Generable
    struct Arguments {
        @Guide(description: "The day to fetch steps for, in ISO 8601 (yyyy-MM-dd)")
        let date: String
    }

    func call(arguments: Arguments) async throws -> ToolOutput {
        let steps = try await HealthStore.shared.steps(onISODate: arguments.date)
        return ToolOutput("\(steps) steps")
    }
}

Confirm the exact Tool requirements and ToolOutput construction against your SDK — names were adjusted across iOS 26 point releases. The architecture is stable: name + description + @Generable arguments + async call.

Attach tools when creating the session; the model decides whether and when to call them:

let session = LanguageModelSession(tools: [StepCountTool()])
let answer = try await session.respond(
    to: "How active was I yesterday compared to a typical day?"
)

The model will call getStepCount (possibly more than once), get real numbers back, and reason about them in its reply.

The description is the API contract

The description strings — on the tool and on each @Guide — are not documentation, they're the interface the model programs against. The model decides whether to call the tool, and how to fill its arguments, purely from these strings. Vague descriptions produce wrong or missing calls.

  • Tool description: state exactly what it returns and when it's appropriate. "Returns the user's step count for a given day" beats "health stuff."
  • Argument guides: specify format precisely. The ISO-8601 note above is what makes the model pass a parseable date instead of "yesterday."

Treat description-writing as prompt engineering, because it is.

Handling permissions inside the tool

Real system data needs authorization, and the user may not have granted it. The tool must handle this — the model has no concept of HealthKit permissions.

func call(arguments: Arguments) async throws -> ToolOutput {
    guard await HealthStore.shared.isAuthorized(for: .stepCount) else {
        // Tell the MODEL, in words it can relay, that data isn't available.
        return ToolOutput("Step data is unavailable because Health access isn't granted.")
    }
    let steps = try await HealthStore.shared.steps(onISODate: arguments.date)
    return ToolOutput("\(steps) steps")
}

Key principle: a tool returns an honest result the model can incorporate, rather than throwing for expected conditions. If you throw on "not authorized," you abort the whole generation. If you return a clear sentence, the model gracefully tells the user it can't see their steps. Reserve throws for genuine errors; model expected-empty/denied states as normal ToolOutput.

Keeping the model honest

The risk with tool calling is the model narrating around your data — inventing context, extrapolating beyond what the tool returned. Mitigations:

  • Return structured, specific values, not prose the model can embroider. "8,432 steps" not "a pretty active day."
  • Constrain the final answer with guided generation when you need the response itself structured — combine tools with a @Generable output type.
  • Set instructions that bound interpretation: "Base activity claims only on the step data returned by tools; do not estimate." This curbs hallucinated specifics.

A MapKit example: live data the model can't know

struct NearbyPlacesTool: Tool {
    let name = "findNearbyPlaces"
    let description = "Finds places of a given category near the user's current location."

    @Generable
    struct Arguments {
        @Guide(description: "Category to search, e.g. 'coffee', 'pharmacy'")
        let category: String
    }

    func call(arguments: Arguments) async throws -> ToolOutput {
        guard let coordinate = await LocationProvider.shared.current() else {
            return ToolOutput("Location is unavailable; can't search nearby.")
        }
        let places = try await MapSearch.run(arguments.category, near: coordinate)
        guard !places.isEmpty else {
            return ToolOutput("No \(arguments.category) found nearby.")
        }
        // Feed the model concrete, bounded facts.
        let list = places.prefix(5).map { "\($0.name) — \($0.distance)m" }
                                   .joined(separator: "; ")
        return ToolOutput(list)
    }
}

This is the real value of tool calling: the on-device model has no idea where the user is or what's around them, but with this tool it can answer "where's the nearest pharmacy?" using live device data — all on-device, no server.

Production checklist

  • Write tool/argument descriptions as the model's API contract — precise, format-specified.
  • Handle permissions inside the tool, returning honest ToolOutput rather than throwing on expected denials.
  • Return specific structured values, not embroiderable prose.
  • Bound interpretation via instructions to curb hallucinated specifics.
  • Reserve throws for genuine errors; model empty/denied as normal output.
  • Confirm Tool/ToolOutput signatures against your SDK; keep them isolated.

Why this matters for shipped apps

Tool calling is what turns the on-device model from a text generator into an agent over the user's real context — health, location, calendar, your app's own data — without anything leaving the device. But the gap between the demo and a shippable feature is entirely in the parts tutorials skip: permission handling, honest failure output, and keeping the model anchored to real returned data instead of plausible fiction. Get those right and you have a genuinely useful private assistant; skip them and you ship confident hallucinations.

We've wired Foundation Models tools to HealthKit and MapKit in production. If you're building an on-device agent over real device data, that's squarely our on-device AI integration work at 3NSOFTS.