# Project export: ToneGuard

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: OpenAI Build Week
- Tagline: A privacy-first, on-device AI that flags toxic or off-tone language in real time—helping people communicate thoughtfully without sending their text to the internet.
- Devpost: https://devpost.com/software/toneguard
- GitHub: https://github.com/vivekjain202/ToneGuard
- Video: https://www.youtube.com/embed/E6RBstuPtBM?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — Vivek Jain (4 commits)

## Devpost submission (written by the team)

### Inspiration

Tone shapes how people understand our intentions. A message meant to be direct can sound harsh, and a quick reply can unintentionally feel negative. We built ToneGuard to help people communicate more thoughtfully while keeping their conversations private.

### What it does

ToneGuard detects toxic, negative, or off-tone language in text and highlights it before it is shared. It runs entirely on-device, with no internet access or cloud processing, so user text remains private.

### How we built it

We built ToneGuard as a native macOS app using Swift and integrated an on-device ML model to analyze text locally. The app provides real-time feedback by identifying language that may be interpreted negatively or differently than intended.

### Challenges we ran into

The main challenge was building for Swift and macOS, since this was a new ecosystem for us. Understanding native app development, UI integration, and on-device model inference took time. Codex helped bridge this gap: we defined the problem and product direction, while Codex supported the implementation details.

### Accomplishments we're proud of

We are proud to have created a working privacy-first communication tool that does not rely on the internet. ToneGuard demonstrates that helpful AI does not need to collect or send sensitive user data to be effective.

### What we learned

We learned how to build a macOS app, integrate an ML model for local inference, and design around privacy from the start. We also learned that AI products are most valuable when they support people at the exact moment they need help—in this case, before a message is sent.

### What's next

ToneGuard currently supports English. Next, we plan to improve tone detection, provide clearer rewrite suggestions, and extend support to other languages—while preserving its privacy-first, on-device approach.

## README (from the GitHub repository)

# ToneGuard

A native, local-first macOS MVP that watches the focused accessibility text field,
detects toxic language in real time, and shows a transparent highlight over the
matching words. No text leaves the device.

## Run

```bash
chmod +x Scripts/build-app.sh
pkill -x ToneGuard 2>/dev/null || true
Scripts/build-app.sh
open -n dist/ToneGuard.app
```

ToneGuard prompts for **Accessibility** permission immediately on launch. Grant it
in System Settings → Privacy & Security → Accessibility; it will appear as
**ToneGuard** because it runs as a native `.app` bundle. Click inside an editable
field in another app and type a configured term such as `idiot` or `hate`.

`swift run ToneGuard` remains useful for development, but is not the recommended
way to request Accessibility access because it does not launch a stable `.app`
bundle identity.

## Diagnostics

The menu-bar command **Preview overlay (5 seconds)** draws a large red rectangle
without reading any other app. If it is not visible, the macOS overlay window is
being blocked before text-range geometry is involved. Diagnostic metadata (never
typed text) is also written to:

```text
~/Library/Application Support/ToneGuard/Logs/toneguard.log
```

## MVP controls

Choose **Detection Settings** from the menu-bar popover to enable categories,
adjust sensitivity, or add local custom blocked words. A detection includes its
category, confidence, and a short explanation. These settings are stored only in
your local macOS user defaults.

ToneGuard also uses macOS's built-in, on-device NaturalLanguage sentiment model
to flag strongly negative sentences that do not contain an exact blocked word.
Those detections highlight the full sentence; direct rule matches remain
word-level highlights.

## Privacy

ToneGuard has no networking code or remote model calls: detection rules, custom
words, and the system-provided sentiment model run only on the Mac. The main app
must remain outside the restrictive App Sandbox because macOS uses that sandbox
to block the cross-app Accessibility API ToneGuard needs. A production-grade
network guarantee therefore requires a separately sandboxed, no-network helper
or an outbound firewall policy; it cannot coexist with direct cross-app
Accessibility in one sandboxed process.

## Validation

The detector has unit tests in `Tests/ToneGuardTests`. Run them in Xcode (or with
`swift test` on a full Xcode installation). Manual acceptance checks: grant
Accessibility access to `ToneGuard.app`, then verify the terms `idiot`, `shut
up`, and a custom blocked word in TextEdit, Notes, and a browser editor. Some
apps intentionally do not expose text bounds through Accessibility; ToneGuard
will continue monitoring without drawing an overlay in those editors.

## Notes

- macOS only exposes text from apps that support the Accessibility API. Secure text
  fields and some third-party apps intentionally do not expose their contents.
- Terminal and iTerm transcripts are intentionally skipped in this MVP because
  macOS reports their entire scrollback as a single Accessibility text region.
- The MVP uses a small local rules engine, deliberately kept behind `ToxicityDetector`
  so it can be replaced by a bundled Core ML model later without changing the UI or
  accessibility pipeline.
- The Swift package makes it easy to develop without an Xcode project. For signing
  and distribution, open the folder in Xcode and use a Developer ID / sandbox
  configuration with the Accessibility entitlement workflow.


## Detected evidence (automated analysis)

Indexed codebase: 19 recognized source files, 62 KB.
- Swift (language) — detected in the code

## Codebase structure (from repository index)

### Files (22 of 22)

```
.gitignore
Package.swift
README.md
Resources/AppIcon.icns
Resources/Info.plist
Scripts/build-app.sh
Scripts/make-icon.swift
Sources/ToneGuard/AccessibilityMonitor.swift
Sources/ToneGuard/ApplicationCatalog.swift
Sources/ToneGuard/AppLogger.swift
Sources/ToneGuard/AppModel.swift
Sources/ToneGuard/ContentView.swift
Sources/ToneGuard/DetectionSettings.swift
Sources/ToneGuard/HighlightOverlayController.swift
Sources/ToneGuard/MainWindowController.swift
Sources/ToneGuard/Models.swift
Sources/ToneGuard/SettingsView.swift
Sources/ToneGuard/ToneGuardApp.swift
Sources/ToneGuard/ToneMLDetector.swift
Sources/ToneGuard/ToxicityDetector.swift
task.md
Tests/ToneGuardTests/ToxicityDetectorTests.swift
```

### Dependencies

No dependency index available.

### Recent commits (newest first)

- feat: exclude selected applications from scanning (#2)
- Merge pull request #1 from vivekjain202/codex/initial-toneguard-mvp
- chore: initialize repository
- feat: add ToneGuard macOS MVP

## Key source files (fetched from GitHub, selected and truncated for size)

### task.md

```markdown
# ToneGuard MVP task list

## Goal

Ship a native macOS app that detects potentially toxic wording locally and in
real time, then highlights it in supported apps through the macOS Accessibility
API. The architecture should leave room for a Windows implementation later.

## Current MVP tasks

- [x] **1. App foundation** — Create the native SwiftUI macOS app, menu-bar
  controls, local-only privacy messaging, and a stable `.app` bundle.
- [x] **2. Accessibility onboarding** — Prompt at launch, guide the user to
  grant permission, and automatically begin monitoring after approval.
- [x] **3. Focused-text monitor** — Read the focused editable Accessibility
  element on a short polling interval without using a network service.
- [x] **4. Local toxicity detector** — Add a deterministic, local rules engine
  behind a replaceable detector interface.
- [x] **5. Cross-app highlight overlay** — Draw a click-through overlay for
  detected text ranges when the target app exposes range bounds to Accessibility.
- [x] **6. Detection settings** — Users can enable categories, tune sensitivity,
  and manage their own local blocked-word list.
- [x] **7. Explain detections** — The app shows each matched phrase, category,
  confidence, and a concise reason.
- [x] **8. Reliability pass** — The monitor ignores secure/static elements,
  clears stale overlays on focus changes, and recovers after permission changes.
- [x] **9. Test coverage** — Detector unit tests and a documented manual
  acceptance checklist cover rules, settings, and supported editors.
- [x] **10. Distribution readiness** — Added a generated app icon, stable bundle
  identifier, versioned metadata, and repeatable `.app` build workflow.
- [x] **11. On-device tone model** — Added macOS NaturalLanguage sentiment
  scoring with full-sentence highlights for strongly negative tone.

## Future (not MVP)

- [ ] Replace rules with a bundled Core ML tone classifier.
- [ ] Suggest kinder rewrites locally.
- [ ] Replace selected text only after explicit user approval.
- [ ] Build a Windows frontend over the same detector contract.

## Working agreement

All current MVP tasks are complete. Future work begins only after the MVP is
manually accepted in the target editors.

```

### Package.swift

```swift
// swift-tools-version: 6.0
import PackageDescription

let package = Package(
    name: "ToneGuard",
    platforms: [.macOS(.v14)],
    products: [
        .executable(name: "ToneGuard", targets: ["ToneGuard"])
    ],
    targets: [
        .executableTarget(name: "ToneGuard"),
        .testTarget(name: "ToneGuardTests", dependencies: ["ToneGuard"])
    ]
)

```

### Scripts/build-app.sh

```shell
#!/bin/zsh
set -euo pipefail

ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$ROOT/.build"
APP="$ROOT/dist/ToneGuard.app"

# `--show-bin-path` only reports a location; it does not compile. Build first so
# every packaged app contains the current Swift sources rather than a stale binary.
swift build --package-path "$ROOT" --scratch-path "$BUILD_DIR"
BIN="$(swift build --package-path "$ROOT" --scratch-path "$BUILD_DIR" --show-bin-path)/ToneGuard"

if [[ ! -f "$ROOT/Resources/AppIcon.icns" ]]; then
  swift "$ROOT/Scripts/make-icon.swift" "$ROOT"
  iconutil -c icns "$ROOT/Resources/AppIcon.iconset" -o "$ROOT/Resources/AppIcon.icns"
  rm -rf "$ROOT/Resources/AppIcon.iconset"
fi

# Preserve the bundle directory between builds. TCC associates Accessibility
# approval with this installed app location as well as its code requirement.
mkdir -p "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp "$BIN" "$APP/Contents/MacOS/ToneGuard"
cp "$ROOT/Resources/Info.plist" "$APP/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $(date +%Y%m%d%H%M%S)" "$APP/Contents/Info.plist"
if [[ -f "$ROOT/Resources/AppIcon.icns" ]]; then
  cp "$ROOT/Resources/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns"
fi

# System-wide Accessibility is not compatible with the restrictive App Sandbox.
# Use a stable local designated requirement so TCC recognizes development rebuilds
# as the same Accessibility client. Release builds should use Developer ID signing.
codesign --force --sign - -r='designated => identifier "com.vivekjain.toneguard"' "$APP"

echo "Built $APP"
echo "Open the .app (not swift run) to grant accessibility access to ToneGuard."

```

### Scripts/make-icon.swift

```swift
import AppKit
import Foundation

let root = URL(fileURLWithPath: CommandLine.arguments.dropFirst().first ?? FileManager.default.currentDirectoryPath)
let iconset = root.appendingPathComponent("Resources/AppIcon.iconset")
try? FileManager.default.removeItem(at: iconset)
try FileManager.default.createDirectory(at: iconset, withIntermediateDirectories: true)

let sizes: [(String, Int)] = [
    ("icon_16x16.png", 16), ("icon_16x16@2x.png", 32),
    ("icon_32x32.png", 32), ("icon_32x32@2x.png", 64),
    ("icon_128x128.png", 128), ("icon_128x128@2x.png", 256),
    ("icon_256x256.png", 256), ("icon_256x256@2x.png", 512),
    ("icon_512x512.png", 512), ("icon_512x512@2x.png", 1024)
]

func drawIcon(size: Int) -> Data? {
    let side = CGFloat(size)
    let image = NSImage(size: NSSize(width: side, height: side))
    image.lockFocus()
    let canvas = NSRect(x: 0, y: 0, width: side, height: side)
    let radius = side * 0.22
    let background = NSBezierPath(roundedRect: canvas.insetBy(dx: 1, dy: 1), xRadius: radius, yRadius: radius)
    NSGradient(colors: [NSColor(red: 0.16, green: 0.12, blue: 0.44, alpha: 1), NSColor(red: 0.37, green: 0.19, blue: 0.69, alpha: 1)])?
        .draw(in: background, angle: -45)

    let shield = NSBezierPath()
    shield.move(to: NSPoint(x: side * 0.50, y: side * 0.80))
    shield.curve(to: NSPoint(x: side * 0.76, y: side * 0.68), controlPoint1: NSPoint(x: side * 0.59, y: side * 0.80), controlPoint2: NSPoint(x: side * 0.70, y: side * 0.75))
    shield.line(to: NSPoint(x: side * 0.71, y: side * 0.40))
    shield.curve(to: NSPoint(x: side * 0.50, y: side * 0.17), controlPoint1: NSPoint(x: side * 0.68, y: side * 0.29), controlPoint2: NSPoint(x: side * 0.57, y: side * 0.20))
    shield.curve(to: NSPoint(x: side * 0.29, y: side * 0.40), controlPoint1: NSPoint(x: side * 0.43, y: side * 0.20), controlPoint2: NSPoint(x: side * 0.32, y: side * 0.29))
    shield.line(to: NSPoint(x: side * 0.24, y: side * 0.68))
    shield.curve(to: NSPoint(x: side * 0.50, y: side * 0.80), controlPoint1: NSPoint(x: side * 0.30, y: side * 0.75), controlPoint2: NSPoint(x: side * 0.41, y: side * 0.80))
    shield.close()
    NSColor.white.withAlphaComponent(0.96).setFill()
    shield.fill()

    let bubble = NSBezierPath(roundedRect: NSRect(x: side * 0.35, y: side * 0.43, width: side * 0.30, height: side * 0.18), xRadius: side * 0.05, yRadius: side * 0.05)
    NSColor(red: 0.28, green: 0.15, blue: 0.61, alpha: 1).setFill()
    bubble.fill()
    let tail = NSBezierPath()
    tail.move(to: NSPoint(x: side * 0.44, y: side * 0.44))
    tail.line(to: NSPoint(x: side * 0.42, y: side * 0.36))
    tail.line(to: NSPoint(x: side * 0.51, y: side * 0.44))
    tail.close()
    tail.fill()
    NSColor(red: 0.98, green: 0.37, blue: 0.37, alpha: 1).setFill()
    NSBezierPath(roundedRect: NSRect(x: side * 0.40, y: side * 0.50, width: side * 0.20, height: max(1, side * 0.025)), xRadius: 2, yRadius: 2).fill()

    image.unlockFocus()
    return image.tiffRepresentation.flatMap { NSBitmapImageRep(data: $0)?.representation(using: .png, properties: [:]) }
}

for (name, size) in sizes {
    guard let data = drawIcon(size: size) else { fatalError("Could not render \(name)") }
    try data.write(to: iconset.appendingPathComponent(name))
}

print("Rendered \(iconset.path)")

```

### Sources/ToneGuard/ToneGuardApp.swift

```swift
import SwiftUI

@main
struct ToneGuardApp: App {
    @NSApplicationDelegateAdaptor(ToneGuardAppDelegate.self) private var appDelegate
    @StateObject private var appModel = AppModel.shared

    var body: some Scene {
        MenuBarExtra("ToneGuard", systemImage: appModel.isMonitoring ? "shield.lefthalf.filled" : "shield") {
            MenuBarView(model: appModel)
        }
        .menuBarExtraStyle(.window)

        Settings {
            SettingsView(monitor: appModel.monitor)
                .frame(minWidth: 480, minHeight: 440)
        }
    }
}

```

### Sources/ToneGuard/AppModel.swift

```swift
import Foundation
import Combine

@MainActor
final class AppModel: ObservableObject {
    static let shared = AppModel()
    @Published private(set) var monitor = AccessibilityMonitor()
    private var monitorChanges: AnyCancellable?

    private init() {
        monitorChanges = monitor.objectWillChange.sink { [weak self] _ in
            self?.objectWillChange.send()
        }
    }

    var isMonitoring: Bool { monitor.isMonitoring }

    /// Called when the native app starts. This intentionally prompts before the
    /// user needs to interact with the main window.
    func startOnLaunch() {
        monitor.start()
    }

    func toggleMonitoring() {
        monitor.isMonitoring ? monitor.stop() : monitor.start()
    }
}

```

### Sources/ToneGuard/MainWindowController.swift

```swift
import AppKit
import SwiftUI

@MainActor
final class MainWindowController: NSWindowController {
    static let shared = MainWindowController()

    private init() {
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 620, height: 680),
            styleMask: [.titled, .closable, .miniaturizable, .resizable],
            backing: .buffered,
            defer: false
        )
        window.title = "ToneGuard"
        window.center()
        window.isReleasedWhenClosed = false
        window.contentView = NSHostingView(rootView: ContentView(model: AppModel.shared))
        super.init(window: window)
    }

    func show() {
        window?.makeKeyAndOrderFront(nil)
        NSApp.activate(ignoringOtherApps: true)
    }

    @available(*, unavailable)
    required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}

final class ToneGuardAppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        DispatchQueue.main.async {
            MainWindowController.shared.show()
        }
    }

    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool {
        MainWindowController.shared.show()
        return true
    }
}

```

### Sources/ToneGuard/Models.swift

```swift
import Foundation

enum ToxicityCategory: String, CaseIterable, Identifiable, Codable, Hashable {
    case insult = "Insult"
    case hostility = "Hostile tone"
    case profanity = "Profanity"

    var id: String { rawValue }

    var colorName: String {
        switch self {
        case .insult: "orange"
        case .hostility: "red"
        case .profanity: "purple"
        }
    }

    var reason: String {
        switch self {
        case .insult: "This can read as a personal insult."
        case .hostility: "This can read as hostile or dismissive."
        case .profanity: "This may be inappropriate for a professional audience."
        }
    }
}

struct ToxicityMatch: Identifiable {
    let id = UUID()
    let range: NSRange
    let phrase: String
    let category: ToxicityCategory
    let confidence: Double
    let reason: String
}

extension ToxicityMatch: Equatable {
    static func == (lhs: ToxicityMatch, rhs: ToxicityMatch) -> Bool {
        lhs.range == rhs.range && lhs.phrase == rhs.phrase && lhs.category == rhs.category && lhs.confidence == rhs.confidence && lhs.reason == rhs.reason
    }
}

struct DetectionSnapshot {
    let text: String
    let matches: [ToxicityMatch]
    let applicationName: String
    let updatedAt: Date

    static let empty = DetectionSnapshot(text: "", matches: [], applicationName: "No active editor", updatedAt: .now)
}

```

### Sources/ToneGuard/ToneMLDetector.swift

```swift
import Foundation
import NaturalLanguage

/// Uses macOS's built-in NaturalLanguage sentiment model. The model runs entirely
/// on-device and needs no model download, network request, or user text storage.
struct ToneMLDetector {
    func detectNegativeTone(in text: String, threshold: Double) -> [ToxicityMatch] {
        let tokenizer = NLTokenizer(unit: .sentence)
        tokenizer.string = text
        var matches: [ToxicityMatch] = []

        tokenizer.enumerateTokens(in: text.startIndex..<text.endIndex) { range, _ in
            let sentence = String(text[range])
            guard let score = sentimentScore(for: sentence), score <= threshold else { return true }
            let nsRange = NSRange(range, in: text)
            let confidence = min(0.99, max(0.50, -score))
            matches.append(ToxicityMatch(
                range: nsRange,
                phrase: sentence.trimmingCharacters(in: .whitespacesAndNewlines),
                category: .hostility,
                confidence: confidence,
                reason: "The on-device tone model found this sentence strongly negative (sentiment \(String(format: "%.2f", score)))."
            ))
            return true
        }
        return matches
    }

    private func sentimentScore(for text: String) -> Double? {
        let tagger = NLTagger(tagSchemes: [.sentimentScore])
        tagger.string = text
        tagger.setLanguage(.english, range: text.startIndex..<text.endIndex)
        let (tag, _) = tagger.tag(at: text.startIndex, unit: .paragraph, scheme: .sentimentScore)
        guard let tag,
              let score = Double(tag.rawValue) else { return nil }
        return score
    }
}

```

### Sources/ToneGuard/ApplicationCatalog.swift

```swift
import AppKit
import Foundation

struct ApplicationOption: Identifiable, Hashable {
    let bundleID: String
    let name: String

    var id: String { bundleID }
}

enum ApplicationCatalog {
    static func discover() -> [ApplicationOption] {
        let manager = FileManager.default
        var apps: [String: ApplicationOption] = [:]

        for app in NSWorkspace.shared.runningApplications {
            guard let bundleID = app.bundleIdentifier else { continue }
            apps[bundleID] = ApplicationOption(bundleID: bundleID, name: app.localizedName ?? bundleID)
        }

        let roots: [URL] = [
            URL(fileURLWithPath: "/Applications", isDirectory: true),
            URL(fileURLWithPath: "/System/Applications", isDirectory: true),
            manager.urls(for: .applicationDirectory, in: .userDomainMask).first
        ].compactMap { $0 }

        for root in roots {
            guard let enumerator = manager.enumerator(
                at: root,
                includingPropertiesForKeys: [.isDirectoryKey],
                options: [.skipsHiddenFiles, .skipsPackageDescendants]
            ) else { continue }
            for case let url as URL in enumerator where url.pathExtension == "app" {
                guard let bundle = Bundle(url: url), let bundleID = bundle.bundleIdentifier else { continue }
                let name = (bundle.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String)
                    ?? (bundle.object(forInfoDictionaryKey: "CFBundleName") as? String)
                    ?? url.deletingPathExtension().lastPathComponent
                apps[bundleID] = ApplicationOption(bundleID: bundleID, name: name)
            }
        }

        return apps.values.sorted {
            $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
        }
    }
}

```

[8 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]